Skip to content

Project

A Premiere Pro project.

Source code in src/py_premiere/models/project.py
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 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
1252
1253
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
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
class Project:
    """A Premiere Pro project."""

    def __init__(self, _document: PremiereDocument, path: Path) -> None:
        self._document = _document
        self._path = path
        self._root_item: ProjectItem | None = None
        self._sequences: list[Sequence] = []
        self._active_sequence: Sequence | None = None
        self._items_by_master_uid: dict[str, ProjectItem] | None = None
        self._items_by_sequence_uid: dict[str, ProjectItem] | None = None
        #: The bin `import_files` is currently importing into, if any.
        self._import_target: ProjectItem | None = None
        #: Overrides the machine-profile discovery of `Preferences` when set
        #: (threaded from `parse(preferences_path=...)` / `new(...)`).
        self._preferences_path: Path | None = None
        #: `Media` objects synthesized by `import_files`, whose RelativePath
        #: is re-derived at save time against the destination directory.
        self._imported_media: list[ET.Element] = []

    @property
    def name(self) -> str:
        """The project file name. Read-only."""
        return self._path.name

    @property
    def path(self) -> Path:
        """The project file path. Read-only."""
        return self._path

    @property
    def root_item(self) -> ProjectItem | None:
        """The root of the item tree. Read-only."""
        return self._root_item

    @property
    def document_id(self) -> str | None:
        """The project's persistent identifier. Read-only."""
        if self._root_item is None:
            return None
        return self._root_item._element.get("ObjectUID")

    @property
    def active_sequence(self) -> Sequence | None:
        """The frontmost sequence, if any. Read-only.

        Best-effort: Premiere stores no dedicated key; this derives from
        the open-sequence list and can differ from the session state.
        """
        return self._active_sequence

    @property
    def sequences(self) -> NamedList[Sequence]:
        """The project's sequences, indexable by name. Read-only."""
        return NamedList(self._sequences)

    @staticmethod
    def sequence_presets() -> list[str]:
        """The preset names accepted by `add_sequence`. Read-only."""
        return sorted(FORMATS)

    def _item_by_master_uid(self, uid: str) -> ProjectItem | None:
        # Maps a MasterClip's persistent UID to the owning project item,
        # so a track item can resolve its source item. Built once per parse.
        if self._items_by_master_uid is None:
            mapping: dict[str, ProjectItem] = {}

            def visit(item: ProjectItem) -> None:
                master = item._master_element
                if master is not None:
                    master_uid = master.get("ObjectUID")
                    if master_uid is not None:
                        mapping[master_uid] = item
                for child in item.children:
                    visit(child)

            if self._root_item is not None:
                visit(self._root_item)
            self._items_by_master_uid = mapping
        return self._items_by_master_uid.get(uid)

    def _item_by_sequence_uid(self, uid: str) -> ProjectItem | None:
        # Maps a sequence's UID to its project-panel item.
        if self._items_by_sequence_uid is None:
            mapping: dict[str, ProjectItem] = {}

            def visit(item: ProjectItem) -> None:
                if item._sequence_uid is not None:
                    mapping[item._sequence_uid] = item
                for child in item.children:
                    visit(child)

            if self._root_item is not None:
                visit(self._root_item)
            self._items_by_sequence_uid = mapping
        return self._items_by_sequence_uid.get(uid)

    def import_files(
        self, paths: list[str | Path], target_bin: ProjectItem | None = None
    ) -> list[ProjectItem]:
        """Import media files into the project panel.

        Synthesizes the same object graph Premiere writes for a fresh
        import. Supported so far: BMP/PNG/JPEG/GIF/TIFF/PSD stills; audio -
        WAV (16/24-bit PCM or 32-bit float, any channel count), AIFF, M4A and
        WMA; and video - uncompressed AVI, MJPEG-in-AVI,
        H.264/H.265/ProRes/DNxHR in MP4 and MOV (with or without an AAC
        audio track), MPEG-2 in MXF, and MP3/AAC. The content-state
        hashes Premiere stamps are change-detection caches, so py writes a
        fresh GUID and Premiere refreshes it on open. Premiere's
        auto-transcript bootstrap objects and its machine-local audio
        conform-cache paths are elided (regenerated on open).

        `target_bin` is ExtendScript's `importFiles` parameter of the same
        name: the bin the new items land in, defaulting to the panel root.
        """
        if target_bin is not None:
            if target_bin._type is not ProjectItemType.BIN:
                raise ValueError("target_bin must be a bin")
            if target_bin.project is not self:
                # The objects land in THIS document while the item reference
                # is written into the other one, so both come out broken: the
                # import is unreachable here and the donor gains a ref to an
                # ObjectUID it does not contain.
                raise ValueError("target_bin belongs to another project")
        # Import defaults come from the machine's own Premiere preferences
        # (matching what the user's Premiere would write); the factory
        # constants cover machines without one (e.g. CI). Read once per call:
        # locating and parsing the prefs file is not cheap.
        if self._preferences_path is not None:
            preferences: Preferences | None = Preferences(self._preferences_path)
        else:
            preferences = Preferences.load_default()
        items = []
        previous, self._import_target = self._import_target, target_bin
        try:
            for path in paths:
                path = Path(path)
                suffix = path.suffix.lower()
                if suffix in AUDIO_FORMATS:
                    items.append(self._import_audio(path, preferences))
                elif suffix in MOVIE_SUFFIXES:
                    items.append(self._import_video(path, preferences))
                elif suffix == ".srt":
                    items.append(self._import_caption(path, preferences))
                else:
                    items.append(self._import_still(path, preferences))
        finally:
            self._import_target = previous
        return items

    def _import_caption(
        self, path: Path, preferences: Preferences | None
    ) -> ProjectItem:
        # An SRT lands as a TranscriptClip-backed panel item whose
        # CaptionCollection holds one Caption per cue; the styled-text
        # payloads are template patches (29_captions).
        _validate_media_path(path)
        cues = parse_srt(path.read_text(encoding="utf-8-sig"))
        label_index, label_color = self._label(
            preferences, "Captions", CAPTIONS_LABEL_INDEX, CAPTIONS_LABEL_COLOR
        )
        # The stream's duration is the last cue end, on whole caption-rate
        # frames (2 s in the fixture; exact there, so the rounding
        # direction is py's own choice - up, to cover the final cue).
        last_end = max(cue.end for cue in cues)
        frames = -(-last_end // CAPTION_FRAME_RATE)
        duration = frames * CAPTION_FRAME_RATE
        name = path.name
        file_path = str(path.resolve())
        project_dir = str(self._path.resolve().parent)
        content_state = str(uuid.uuid4())
        stream_id = self._register(new_data_stream(duration))
        media_uid = str(uuid.uuid4())
        self._imported_media.append(
            self._register_uid(
                new_data_media(
                    media_uid, stream_id, file_path, project_dir, content_state
                ),
                media_uid,
            )
        )
        source_id = self._register(new_data_media_source(media_uid, duration))
        markers_id = self._register(new_markers_collection(content_state))
        logging_id = self._register(
            new_media_logging_info(
                name,
                CAPTION_FRAME_RATE,
                duration,
                # Caption logging records no capture mode (like A/V media).
                None,
                CAPTION_TIMECODE_FORMAT,
                0,
            )
        )
        entries = []
        for cue in cues:
            payload = base64.b64encode(build_caption_payload(cue.text)).decode("ascii")
            block_id = self._register(new_block(payload, str(uuid.uuid4())))
            entries.append(
                (cue.end, self._register(new_caption(block_id, cue.start, cue.end)))
            )
        collection_id = self._register(new_caption_collection(entries))
        clip_id = self._register(
            new_transcript_clip(
                markers_id,
                source_id,
                collection_id,
                label_index,
                label_color,
                str(uuid.uuid4()),
            )
        )
        groups_id = self._register(new_channel_groups())
        master_uid = str(uuid.uuid4())
        master = self._register_uid(
            new_master_clip(master_uid, logging_id, clip_id, groups_id, name),
            master_uid,
        )
        item_uid = str(uuid.uuid4())
        item_element = self._register_uid(
            new_clip_item(item_uid, master_uid, name, label_index), item_uid
        )
        return self._attach_imported_item(
            item_element, item_uid, master, [clip_id], file_path, duration
        )

    def _register(self, element: ET.Element) -> str:
        """Add a new object to the top-level table under a fresh ObjectID."""
        return self._document.add_object(element)

    def _register_uid(self, element: ET.Element, uid: str) -> ET.Element:
        """Add a new object to the top-level table under its own ObjectUID."""
        element.set("ObjectUID", uid)
        return self._document.attach_object(element)

    def _root_bin(self, name: str, build: Callable[[], ET.Element]) -> ProjectItem:
        """A root-level bin of this name, created if it is not there yet.

        Premiere reuses the bins its own features file things into rather
        than adding a numbered second one (79_two_multicams).
        """
        root_item = self.root_item
        if root_item is None:
            raise ValueError("project has no root item")
        container = root_item._element.find("ProjectItemContainer")
        if container is None:
            raise ValueError("root item has no ProjectItemContainer")
        existing = next(
            (
                child
                for child in root_item._children
                if child._type is ProjectItemType.BIN and child.name == name
            ),
            None,
        )
        if existing is not None:
            return existing
        bin_uid = str(uuid.uuid4())
        element = build()
        element.set("ObjectUID", bin_uid)
        self._document.attach_object(element)
        bin_item = ProjectItem(element, self, ProjectItemType.BIN)
        bin_item._parent = root_item
        _add_item_ref(_child_items(container), bin_uid)
        root_item._children.append(bin_item)
        return bin_item

    def _add_template_item(self, master_uid: str, name: str) -> ProjectItem:
        """File an imported Motion Graphics template into its media bin."""
        bin_item = self._root_bin(MEDIA_FOLDER, build_media_bin)
        container = item_container(bin_item._element)
        if container is None:
            raise ValueError("bin has no ProjectItemContainer")
        item_uid = str(uuid.uuid4())
        element = build_template_item(item_uid, master_uid, name)
        self._document.attach_object(element)
        _add_item_ref(_child_items(container), item_uid)
        item = ProjectItem(element, self, ProjectItemType.CLIP)
        item._parent = bin_item
        master = self._document.by_object_uid[master_uid]
        item._master_element = master
        item._clip_elements = [
            self._document.resolve(reference)
            for reference in master.findall("Clips/Clip")
        ]
        bin_item._children.append(item)
        self._items_by_master_uid = None
        return item

    @staticmethod
    def _label(
        preferences: Preferences | None,
        kind: str,
        default_index: int,
        default_color: int,
    ) -> tuple[int, int]:
        # The label a fresh import of this media kind takes, from the local
        # preferences when there are any.
        if preferences is None:
            return default_index, default_color
        index = preferences.label_default(kind)
        if index is None:
            return default_index, default_color
        return index, preferences.label_colors[index]

    def _attach_imported_item(
        self,
        item_element: ET.Element,
        item_uid: str,
        master: ET.Element,
        clip_ids: list[str],
        file_path: str,
        default_out_ticks: int,
    ) -> ProjectItem:
        # Wire a freshly synthesized clip object graph into the panel, under
        # the bin `import_files` was pointed at (the root by default).
        # `is None`, NOT `or`: a ProjectItem is sized by its children, so an
        # EMPTY bin is falsy and `or` would silently import into the root -
        # which is exactly the bin a caller has just created.
        parent = self._import_target
        if parent is None:
            parent = self.root_item
        if parent is None:
            raise ValueError("project has no root item")
        # The STRICT lookup, not `item_container`: that one reaches into a
        # smart bin's nested container, whose contents Premiere regenerates
        # from the bin's query - an item filed there would simply vanish.
        container = parent._element.find("ProjectItemContainer")
        if container is None:
            raise ValueError("target item has no ProjectItemContainer")
        _add_item_ref(_child_items(container), item_uid)
        item = ProjectItem(item_element, self, ProjectItemType.CLIP)
        item._parent = parent
        item._master_element = master
        item._media_path = Path(file_path)
        item._clip_elements = [
            self._document.by_object_id[clip_id] for clip_id in clip_ids
        ]
        item._default_out_ticks = default_out_ticks
        parent._children.append(item)
        self._items_by_master_uid = None
        return item

    def _splice_fragment(self, fragment: ET.Element) -> dict[str, ET.Element]:
        # Attach a builder fragment's objects under fresh identifiers: every
        # ObjectID is renumbered from the document's counter, every ObjectUID
        # reminted, and in-fragment references follow; foreign references
        # (real ids injected by the builder's caller) pass through untouched.
        # Returns the spliced elements keyed by tag (last one wins - the
        # fragments spliced here carry at most one of each tag a caller asks
        # for).
        document = self._document
        id_map: dict[str, str] = {}
        uid_map: dict[str, str] = {}
        next_id = int(document.next_object_id())
        for element in fragment:
            object_id = element.get("ObjectID")
            if object_id is not None:
                id_map[object_id] = str(next_id)
                next_id += 1
            object_uid = element.get("ObjectUID")
            if object_uid is not None:
                uid_map[object_uid] = str(uuid.uuid4())

        def rewrite(node: ET.Element, scoped: bool) -> None:
            for child in node:
                child_scoped = scoped or (
                    child.get("ObjectID") is not None
                    or child.get("ObjectUID") is not None
                )
                if not child_scoped:
                    reference = child.get("ObjectRef")
                    if reference in id_map:
                        child.set("ObjectRef", id_map[reference])
                    reference = child.get("ObjectURef")
                    if reference in uid_map:
                        child.set("ObjectURef", uid_map[reference])
                rewrite(child, child_scoped)

        spliced: dict[str, ET.Element] = {}
        for element in list(fragment):
            object_id = element.get("ObjectID")
            if object_id is not None:
                element.set("ObjectID", id_map[object_id])
            object_uid = element.get("ObjectUID")
            if object_uid is not None:
                element.set("ObjectUID", uid_map[object_uid])
            rewrite(element, False)
            document.attach_object(element)
            spliced[element.tag] = element
        return spliced

    def add_sequence(
        self,
        name: str,
        preset: str = DEFAULT_PRESET,
        video_tracks: int | None = None,
    ) -> Sequence:
        """Create a new sequence and return it.

        Builds the object graph Premiere writes for a new sequence - its
        tracks plus the audio mix graph behind them - and splices it in with
        fresh identifiers. `preset` selects the format; see
        `sequence_presets()` for the names (default 1080p 23.976 fps).

        The track layout follows the preset: 3 video tracks throughout, and
        4 stereo audio tracks except on the mono-discrete broadcast presets,
        where `broadcast8mono` builds 8.

        `video_tracks` overrides the preset's video track count. Every preset
        Adobe ships asks for 3, so a different count is only reachable this
        way; it is checked against sequences Premiere built from `.sqpreset`
        files asking for 1 and 5.

        A sequence Premiere derives from a clip
        (`createNewSequenceFromClips`) has 3 audio tracks instead; only the
        default layout is built here.
        """
        _validate_sequence_name(name)
        if video_tracks is not None:
            validate_track_count(video_tracks)
        document = self._document
        fragment = build_sequence(name, preset, video_tracks=video_tracks)
        spliced = self._splice_fragment(fragment)
        sequence_element = spliced.get("Sequence")
        item_element = spliced.get("ClipProjectItem")
        master_element = spliced.get("MasterClip")
        if sequence_element is None or item_element is None or master_element is None:
            raise ValueError("sequence template is missing its core objects")

        root_item = self.root_item
        if root_item is None:
            raise ValueError("project has no root item")
        container = root_item._element.find("ProjectItemContainer")
        if container is None:
            raise ValueError("root item has no ProjectItemContainer")
        item_uid = item_element.get("ObjectUID")
        if item_uid is None:
            raise ValueError("template panel item has no ObjectUID")
        _add_item_ref(_child_items(container), item_uid)

        # Model -> parser is a proven circular dependency; parsers import
        # the models package at module load.
        from ..parsers.sequence import parse_sequence

        sequence = parse_sequence(document, self, sequence_element)
        self._sequences.append(sequence)
        item = ProjectItem(item_element, self, ProjectItemType.CLIP)
        item._parent = root_item
        item._master_element = master_element
        item._clip_elements = [
            document.resolve(ref) for ref in master_element.findall("Clips/Clip")
        ]
        item._sequence_uid = sequence_element.get("ObjectUID")
        root_item._children.append(item)
        self._items_by_master_uid = None
        self._items_by_sequence_uid = None
        return sequence

    def create_merged_clip(
        self,
        video_item: ProjectItem,
        audio_item: ProjectItem,
        name: str | None = None,
    ) -> ProjectItem:
        """Merge a video clip with an audio clip and return the panel item.

        Premiere has no scripting API for `Merge Clips`; this synthesizes
        the graph its UI writes (23_merged_clip): a hidden sequence flagged
        `BE.Sequence.IsMergedClip` holding PRIVATE COPIES of both source
        graphs on one video and one audio track, a `Link` binding the two
        placements, and a panel item whose master plays that sequence
        through `Video/AudioSequenceSource`. The sources are left
        untouched; the copies are synthesized the way an import is (the
        media files must still be readable) and then take over the sources'
        file identity, like subclips do.

        `name` defaults to Premiere's own: the video clip's name plus
        `" - Merged"`. The fixture-verified shapes are supported: a
        video-only movie plus one mono or stereo audio clip; the audio
        lands as one MONO track per channel, the way Premiere splits it
        (77_merged_stereo).
        """
        if name is not None:
            _validate_item_name(name)
        video_path = video_item.media_path
        audio_path = audio_item.media_path
        if video_path is None or audio_path is None:
            raise ValueError("item has no media file to merge")
        for item, wanted in ((video_item, "VideoClip"), (audio_item, "AudioClip")):
            if item._type is not ProjectItemType.CLIP or item.is_sequence:
                raise ValueError("merged clips are built from media clip items")
            if [element.tag for element in item._clip_elements] != [wanted]:
                raise ValueError(
                    "only a video-only movie plus an audio-only clip can be merged"
                )
        layout = audio_item._clip_elements[0].findtext("AudioChannelLayout")
        if layout == MONO_LAYOUT:
            channels = 1
        elif layout == audio_channel_layout(2):
            channels = 2
        else:
            raise ValueError("only mono or stereo audio is supported in a merged clip")
        document = self._document
        merged_name = name if name is not None else f"{video_item.name} - Merged"

        # Private copies of both source graphs, renamed and stamped the way
        # Premiere's merge does. The MergeClipUtils bag keeps each source's
        # original master name; the duplicated masters and their logging
        # take the merged name.
        video_copy = self.import_files([video_path])[0]
        audio_copy = self.import_files([audio_path])[0]
        copies = (
            (
                video_item,
                video_copy,
                [
                    (
                        "MZ.MergeClipUtils.ComponentMasterClipOriginalName",
                        video_item.name,
                    ),
                ],
            ),
            (
                audio_item,
                audio_copy,
                [
                    ("MZ.MergeClipUtils.AudioTrackNumberFromOriginalMergedClip", "0"),
                    (
                        "MZ.MergeClipUtils.ComponentMasterClipOriginalName",
                        audio_item.name,
                    ),
                ],
            ),
        )
        for source, copy, bag in copies:
            master = copy._master_element
            if master is None:
                raise ValueError("synthesized copy has no master clip")
            master.insert(0, build_master_bag(bag))
            name_element = master.find("Name")
            if name_element is not None:
                name_element.text = merged_name
            logging_ref = master.find("LoggingInfo")
            if logging_ref is not None:
                clip_name = document.resolve(logging_ref).find("ClipName")
                if clip_name is not None:
                    clip_name.text = merged_name
            # A fresh audio import carries a DefMappingID; the duplicated
            # master inside a merged clip does not (23_merged_clip).
            mapping = master.find("DefMappingID")
            if mapping is not None:
                remove_child(master, mapping)
            source._share_file_identity(copy)

        if channels > 1:
            # Premiere's merge splits the audio into per-channel MONO groups
            # over the ONE copied source (77_merged_stereo): the copy keeps
            # its stereo stream and template clip, but its master carries a
            # bag-less mono chain and a single-channel vector per channel
            # instead of the import's stereo pair.
            audio_master = audio_copy._master_element
            if audio_master is None:
                raise ValueError("synthesized copy has no master clip")
            chains_list = audio_master.find("AudioComponentChains")
            groups_ref = audio_master.find("AudioClipChannelGroups")
            if chains_list is None or groups_ref is None:
                raise ValueError("audio copy has no channel plumbing")
            for entry in chains_list.findall("AudioComponentChain"):
                document.remove_object(document.resolve(entry))
            for entry in list(chains_list):
                chains_list.remove(entry)
            _indexed_refs(
                chains_list,
                "AudioComponentChain",
                [self._register(new_master_audio_chain(1, 0)) for _ in range(channels)],
            )
            vectors_list = document.resolve(groups_ref).find("ClipChannelVectors")
            if vectors_list is None:
                raise ValueError("audio copy has no channel vectors")
            for entry in vectors_list.findall("ClipChannelVectorItem"):
                vector = document.resolve(entry)
                for channel_entry in vector.findall("ClipChannels/ClipChannelItem"):
                    document.remove_object(document.resolve(channel_entry))
                document.remove_object(vector)
            for entry in list(vectors_list):
                vectors_list.remove(entry)
            mono_vector_ids = []
            for channel in range(channels):
                serializer_id = self._register(new_channel_serializer(0, channel))
                mono_vector_ids.append(
                    self._register(new_channel_vector([serializer_id], 0))
                )
            _indexed_refs(vectors_list, "ClipChannelVectorItem", mono_vector_ids)

        video_media = video_copy._media_element()
        audio_media = audio_copy._media_element()
        if video_media is None or audio_media is None:
            raise ValueError("synthesized copy has no media")
        video_stream_ref = video_media.find("VideoStream")
        audio_stream_ref = audio_media.find("AudioStream")
        if video_stream_ref is None or audio_stream_ref is None:
            raise ValueError("synthesized copy has no media stream")
        video_stream = document.resolve(video_stream_ref)
        audio_stream = document.resolve(audio_stream_ref)
        timebase = int(video_stream.findtext("FrameRate") or 0)
        audio_rate = int(audio_stream.findtext("FrameRate") or 0)
        video_duration = int(video_stream.findtext("Duration") or 0)
        audio_duration = int(audio_stream.findtext("Duration") or 0)
        rect = (video_stream.findtext("FrameRect") or "0,0,0,0").split(",")
        width, height = int(rect[2]), int(rect[3])
        # Both placements land on whole video frames (`add_clip` floors
        # them); the sequence spans the longer one.
        end_ticks = max(
            video_duration - video_duration % timebase,
            audio_duration - audio_duration % timebase,
        )
        display_format = TIMECODE_FORMATS[(timebase, False)]

        # The hidden sequence: one video track, one audio track per source
        # channel, the audio mix graph behind them, and the merged-clip
        # property bag.
        sequence_uid = str(uuid.uuid4())
        video_track_uid = str(uuid.uuid4())
        audio_track_uids = [str(uuid.uuid4()) for _ in range(channels)]
        group_chain_id = self._register(build_empty_video_chain())
        track_chain_ids = []
        track_pan_ids = []
        for _ in range(channels):
            volume_id = self._register(build_volume_param())
            mute_id = self._register(build_mute_param())
            fader_id = self._register(build_fader(audio_rate, [volume_id, mute_id]))
            meter_id = self._register(build_meter(audio_rate))
            track_chain_ids.append(
                self._register(build_fader_chain(fader_id, meter_id))
            )
            track_pan_ids.append(self._register(build_pan_processor(audio_rate)))
        mix_volume_id = self._register(build_volume_param())
        mix_mute_id = self._register(build_mute_param())
        mix_fader_id = self._register(
            build_fader(audio_rate, [mix_volume_id, mix_mute_id])
        )
        mix_meter_id = self._register(build_meter(audio_rate))
        mix_chain_id = self._register(build_fader_chain(mix_fader_id, mix_meter_id))
        mix_pan_id = self._register(build_pan_processor(audio_rate))
        inlet_id = self._register(build_inlet(audio_track_uids))
        mix_track_id = self._register(
            build_mix_track(mix_chain_id, mix_pan_id, inlet_id)
        )
        video_group_id = self._register(
            build_video_group(video_track_uid, timebase, width, height, group_chain_id)
        )
        audio_group_id = self._register(
            build_audio_group(audio_track_uids, audio_rate, mix_track_id)
        )
        data_group_id = self._register(build_data_group(timebase))
        document.attach_object(build_video_track(video_track_uid))
        for index, track_uid in enumerate(audio_track_uids):
            document.attach_object(
                build_audio_track(
                    track_uid,
                    track_chain_ids[index],
                    track_pan_ids[index],
                    track_id=2 + index,
                    index=index,
                )
            )
        sequence_element = document.attach_object(
            build_merged_sequence(
                sequence_uid,
                merged_name,
                display_format,
                end_ticks,
                width,
                height,
                video_group_id,
                audio_group_id,
                data_group_id,
            )
        )

        # Model -> parser is a proven circular dependency; parsers import
        # the models package at module load.
        from ..parsers.sequence import parse_sequence

        sequence = parse_sequence(document, self, sequence_element)
        self._sequences.append(sequence)
        video_placed = sequence._video_tracks[0].add_clip(video_copy)
        audio_placed_ids = []
        for channel, track in enumerate(sequence._audio_tracks):
            placed = track.add_clip(audio_copy)
            self._narrow_merged_channel(placed, channel)
            audio_placed_ids.append(placed._element.get("ObjectID") or "")

        link_id = self._register(
            build_link([video_placed._element.get("ObjectID") or "", *audio_placed_ids])
        )
        link_entry = sequence_element.find(
            "PersistentGroupContainer/LinkContainer/Links/Link"
        )
        if link_entry is None:
            raise ValueError("merged sequence has no link entry")
        link_entry.set("ObjectRef", link_id)

        # The panel-facing graph: the master's template clips play the
        # hidden sequence through Video/AudioSequenceSource.
        video_source_id = self._register(
            build_sequence_source("VideoSequenceSource", sequence_uid, end_ticks)
        )
        audio_source_id = self._register(
            build_sequence_source("AudioSequenceSource", sequence_uid, end_ticks)
        )
        secondary_id = self._register(new_secondary_content(audio_source_id, 0))
        # One mono chain and one single-channel vector per source channel;
        # the panel-side vectors index the hidden sequence's audio TRACKS
        # (source clip i, channel 0), unlike the copy's (source 0,
        # channel i) - both shapes straight from 77_merged_stereo.
        panel_vector_ids = []
        for index in range(channels):
            channel_id = self._register(new_channel_serializer(index, 0))
            panel_vector_ids.append(self._register(new_channel_vector([channel_id], 0)))
        groups_id = self._register(new_audio_channel_groups(panel_vector_ids))
        master_chain_ids = [
            self._register(new_master_audio_chain(1, 0)) for _ in range(channels)
        ]
        audio_template_id = self._register(
            build_panel_audio_template(audio_source_id, secondary_id)
        )
        video_template_id = self._register(build_panel_video_template(video_source_id))
        logging_id = self._register(
            build_panel_logging(video_item.name, display_format, end_ticks, timebase)
        )
        master_uid = str(uuid.uuid4())
        master_element = document.attach_object(
            build_panel_master(
                master_uid,
                logging_id,
                master_chain_ids,
                audio_template_id,
                video_template_id,
                groups_id,
                merged_name,
            )
        )
        item_uid = str(uuid.uuid4())
        item_element = document.attach_object(
            build_panel_item(item_uid, master_uid, merged_name)
        )

        root_item = self.root_item
        if root_item is None:
            raise ValueError("project has no root item")
        container = root_item._element.find("ProjectItemContainer")
        if container is None:
            raise ValueError("root item has no ProjectItemContainer")
        # The duplicated source graphs stay, but their panel items go: the
        # copies are reachable only through the hidden sequence.
        for copy in (video_copy, audio_copy):
            _detach_item_ref(container, copy._element.get("ObjectUID") or "")
            document.remove_object(copy._element)
            root_item._children.remove(copy)
        _add_item_ref(_child_items(container), item_uid)
        item = ProjectItem(item_element, self, ProjectItemType.CLIP)
        item._parent = root_item
        item._master_element = master_element
        item._clip_elements = [
            document.by_object_id[audio_template_id],
            document.by_object_id[video_template_id],
        ]
        item._default_out_ticks = end_ticks
        item._sequence_uid = sequence_uid
        root_item._children.append(item)
        self._items_by_master_uid = None
        self._items_by_sequence_uid = None
        return item

    def _narrow_merged_channel(self, placed: TrackItem, channel: int) -> None:
        # A merged placement plays ONE mono channel of the copied source
        # (23_merged_clip, 77_merged_stereo): mono layout, a single
        # SecondaryContent for its channel, the channel group selected
        # through the SubClip's OrigChGrp, a SecondaryIndex marker past the
        # first channel, and the bag-less DefaultVol chain instead of the
        # MZ.ActiveComponent-bagged chain of a normal timeline placement.
        document = self._document
        clip = placed._clip_element
        layout_element = clip.find("AudioChannelLayout")
        if layout_element is not None:
            layout_element.text = MONO_LAYOUT
        contents = clip.find("SecondaryContents")
        if contents is not None:
            kept = None
            for entry in contents.findall("SecondaryContentItem"):
                secondary = document.resolve(entry)
                index_text = secondary.findtext("ChannelIndex") or "0"
                if kept is None and int(index_text) == channel:
                    kept = entry
                else:
                    document.remove_object(secondary)
                    remove_child(contents, entry)
            if kept is not None:
                kept.set("Index", "0")
        chain_ref = placed._element.find("ClipTrackItem/ComponentOwner/Components")
        if chain_ref is not None:
            chain_inner = document.resolve(chain_ref).find("ComponentChain")
            node = None if chain_inner is None else chain_inner.find("Node")
            if chain_inner is not None and node is not None:
                chain_inner.remove(node)
                chain_inner.text = "\n\t\t"
        if channel:
            append_leaf(clip, "SecondaryIndex", str(channel))
            orig_group = placed._subclip_element.find("OrigChGrp")
            if orig_group is not None:
                orig_group.text = str(channel)

    def create_multicam_clip(
        self,
        items: list[ProjectItem],
        name: str | None = None,
    ) -> ProjectItem:
        """Build a multicam source clip from angle items and return it.

        Premiere has no scripting API for `Create Multi-Camera Source
        Sequence`; this synthesizes the graph its UI writes (24_multicam):
        a hidden sequence with one video track per angle and a 32-channel
        adaptive audio bus, a master flagged
        `Source.Monitor.Multicam.Enabled`, and the source items filed into
        a new `Processed Clips` bin. Unlike a merged clip nothing is
        copied - the placements play the ORIGINAL source graphs.

        `items` are the angles in track order. `name` defaults to
        Premiere's own: the first angle's name plus `Multicam`. The
        fixture-verified shapes are supported: two or more angles of which
        exactly one carries (stereo) audio - either an AV movie among the
        video angles (its halves come out linked, 24_multicam) or an
        audio-only clip (audio track only, no link, 79_two_multicams).
        Cameras are synchronized at their starts (in-point sync) and every
        angle keeps its full length (78_multicam_3angle).
        """
        if name is not None:
            _validate_item_name(name)
        if len(items) < 2:
            raise ValueError("a multicam clip needs at least two angles")
        audio_angle: ProjectItem | None = None
        video_items = []
        for item in items:
            if item._type is not ProjectItemType.CLIP or item.is_sequence:
                raise ValueError("multicam angles must be media clip items")
            if item.media_path is None:
                raise ValueError("angle has no media file")
            tags = sorted(element.tag for element in item._clip_elements)
            if tags == ["VideoClip"]:
                video_items.append(item)
                continue
            if audio_angle is not None:
                raise ValueError("only one angle may carry audio")
            if tags == ["AudioClip", "VideoClip"]:
                audio_angle = item
                video_items.append(item)
            elif tags == ["AudioClip"]:
                audio_angle = item
            else:
                raise ValueError("multicam angles must be movie or audio clips")
        if audio_angle is None:
            raise ValueError("one angle must carry the audio track")
        if not video_items:
            raise ValueError("a multicam clip needs at least one video angle")
        audio_template = next(
            element
            for element in audio_angle._clip_elements
            if element.tag == "AudioClip"
        )
        if audio_template.findtext("AudioChannelLayout") != audio_channel_layout(2):
            raise ValueError("only a stereo audio angle is supported")
        audio_is_linked = audio_angle in video_items
        document = self._document
        multicam_name = name if name is not None else f"{items[0].name}Multicam"

        # Geometry and display format follow the first video angle; the
        # sequence sources span the longest angle (whole video frames, as
        # the placements land - 78's short angle keeps its own length).
        timebase = 0
        width = height = 0
        end_ticks = 0
        for index, item in enumerate(video_items):
            media = item._media_element()
            stream_ref = None if media is None else media.find("VideoStream")
            if stream_ref is None:
                raise ValueError("angle has no video stream")
            stream = document.resolve(stream_ref)
            rate = int(stream.findtext("FrameRate") or 0)
            duration = int(stream.findtext("Duration") or 0)
            if index == 0:
                timebase = rate
                rect = (stream.findtext("FrameRect") or "0,0,0,0").split(",")
                width, height = int(rect[2]), int(rect[3])
            end_ticks = max(end_ticks, duration - duration % timebase)
        display_format = TIMECODE_FORMATS[(timebase, False)]

        fragment = build_multicam(
            multicam_name,
            timebase,
            width,
            height,
            display_format,
            end_ticks,
            len(video_items),
            link=audio_is_linked,
        )
        spliced = self._splice_fragment(fragment)
        sequence_element = spliced.get("Sequence")
        item_element = spliced.get("ClipProjectItem")
        master_element = spliced.get("MasterClip")
        if sequence_element is None or item_element is None or master_element is None:
            raise ValueError("multicam fragment is missing its core objects")

        # Model -> parser is a proven circular dependency; parsers import
        # the models package at module load.
        from ..parsers.sequence import parse_sequence

        sequence = parse_sequence(document, self, sequence_element)
        self._sequences.append(sequence)
        av_video_placed: TrackItem | None = None
        for index, angle in enumerate(video_items):
            placed = sequence._video_tracks[index].add_clip(angle)
            if angle is audio_angle:
                av_video_placed = placed
        audio_placed = sequence._audio_tracks[0].add_clip(audio_angle)
        # The multicam placement's audio chain is the bag-less stereo
        # DefaultVol shape (24_multicam), not the MZ.ActiveComponent-bagged
        # mono chain of a normal timeline placement.
        chain_ref = audio_placed._element.find(
            "ClipTrackItem/ComponentOwner/Components"
        )
        if chain_ref is not None:
            chain = document.resolve(chain_ref)
            chain_inner = chain.find("ComponentChain")
            node = None if chain_inner is None else chain_inner.find("Node")
            if chain_inner is not None and node is not None:
                chain_inner.remove(node)
                chain_inner.text = "\n\t\t"
            insert_leaf_before(
                chain, "ComponentChain", "DefaultChannelVolumeComponentID", "2"
            )
            append_leaf(chain, "AudioChannelLayout", audio_channel_layout(2))
            append_leaf(chain, "ChannelType", "1")

        if av_video_placed is not None:
            # The Link binds the audio placement to its own angle's video
            # placement, audio first (24_multicam); an audio-only angle has
            # no video half, and Premiere writes no Link at all.
            link_id = self._register(
                build_link(
                    [
                        audio_placed._element.get("ObjectID") or "",
                        av_video_placed._element.get("ObjectID") or "",
                    ]
                )
            )
            link_entry = sequence_element.find(
                "PersistentGroupContainer/LinkContainer/Links/Link"
            )
            if link_entry is None:
                raise ValueError("multicam sequence has no link entry")
            link_entry.set("ObjectRef", link_id)

        # File the source angles into the `Processed Clips` bin, as
        # Premiere does - reusing an existing root-level one
        # (79_two_multicams) or creating it.
        root_item = self.root_item
        if root_item is None:
            raise ValueError("project has no root item")
        root_container = root_item._element.find("ProjectItemContainer")
        if root_container is None:
            raise ValueError("root item has no ProjectItemContainer")
        bin_item = self._root_bin("Processed Clips", build_processed_clips_bin)
        bin_container = item_container(bin_item._element)
        if bin_container is None:
            raise ValueError("bin has no ProjectItemContainer")
        for angle in items:
            if angle._parent is bin_item:
                continue
            parent = angle._parent
            container = None if parent is None else item_container(parent._element)
            angle_uid = angle._element.get("ObjectUID") or ""
            if parent is not None and container is not None:
                _detach_item_ref(container, angle_uid)
                parent._children.remove(angle)
            _add_item_ref(_child_items(bin_container), angle_uid)
            angle._parent = bin_item
            bin_item._children.append(angle)

        item_uid = item_element.get("ObjectUID") or ""
        _add_item_ref(_child_items(root_container), item_uid)
        item = ProjectItem(item_element, self, ProjectItemType.CLIP)
        item._parent = root_item
        item._master_element = master_element
        item._clip_elements = [
            document.resolve(ref) for ref in master_element.findall("Clips/Clip")
        ]
        item._default_out_ticks = end_ticks
        item._sequence_uid = sequence_element.get("ObjectUID")
        root_item._children.append(item)
        self._items_by_master_uid = None
        self._items_by_sequence_uid = None
        return item

    def _import_video(self, path: Path, preferences: Preferences | None) -> ProjectItem:
        _validate_media_path(path)
        (
            width,
            height,
            frame_rate,
            duration,
            container,
            codec,
            audio,
            field_order,
            clip_id,
            start_ticks,
            timecode_format,
        ) = _probe_movie(path)
        if audio is None:
            label_index, label_color = self._label(
                preferences, "Video", VIDEO_LABEL_INDEX, VIDEO_LABEL_COLOR
            )
        else:
            label_index, label_color = self._label(
                preferences, "AV", AV_LABEL_INDEX, AV_LABEL_COLOR
            )

        name = path.name
        file_path = str(path.resolve())
        project_dir = str(self._path.resolve().parent)
        content_state = str(uuid.uuid4())
        stream_id = self._register(
            new_movie_video_stream(
                width, height, frame_rate, duration, container, codec, field_order
            )
        )
        audio_stream_id = None
        audio_rate = None
        if audio is not None:
            audio_rate = TICKS_PER_SECOND // audio.sample_rate
            # Premiere gives the audio stream the MEDIA duration, not the
            # audio track's own (which an encoder can pad or clip).
            audio_stream_id = self._register(
                new_audio_stream(
                    audio_rate,
                    audio_channel_layout(audio.channels),
                    duration,
                    AAC_SAMPLE_TYPE,
                )
            )
        media_uid = str(uuid.uuid4())
        self._imported_media.append(
            self._register_uid(
                new_movie_media(
                    media_uid,
                    stream_id,
                    file_path,
                    project_dir,
                    content_state,
                    audio_stream_id,
                    audio_rate,
                    start_ticks,
                ),
                media_uid,
            )
        )
        source_id = self._register(new_movie_media_source(media_uid, duration))
        markers_id = self._register(new_markers_collection(content_state))
        logging_id = self._register(
            new_media_logging_info(
                name,
                frame_rate,
                duration,
                # Media carrying both streams records no capture mode.
                "2" if audio is None else None,
                str(timecode_format),
                start_ticks,
                clip_id,
            )
        )
        clip_ids = [
            self._register(
                new_movie_template_clip(markers_id, source_id, label_index, label_color)
            )
        ]
        master_uid = str(uuid.uuid4())
        if audio is None:
            groups_id = self._register(new_channel_groups())
            master_element = new_master_clip(
                master_uid, logging_id, clip_ids[0], groups_id, name
            )
        else:
            # The audio half hangs off the SAME Media: its own source, clip
            # and channel group, sharing the item's markers collection.
            channel_type = CHANNEL_TYPES.get(audio.channels)
            if channel_type is None:
                raise NotImplementedError(
                    f"{audio.channels}-channel movie audio is not supported"
                )
            audio_source_id = self._register(
                new_audio_media_source(media_uid, duration)
            )
            secondary_ids = [
                self._register(new_secondary_content(audio_source_id, channel))
                for channel in range(audio.channels)
            ]
            layout = audio_channel_layout(audio.channels)
            clip_ids.append(
                self._register(
                    new_audio_template_clip(
                        markers_id,
                        audio_source_id,
                        secondary_ids,
                        label_index,
                        label_color,
                        layout,
                    )
                )
            )
            channel_ids = [
                self._register(new_channel_serializer(0, channel))
                for channel in range(audio.channels)
            ]
            vector_id = self._register(new_channel_vector(channel_ids, channel_type))
            groups_id = self._register(new_audio_channel_groups([vector_id]))
            chain_id = self._register(
                new_master_audio_chain(audio.channels, channel_type)
            )
            master_element = new_audio_master_clip(
                master_uid, logging_id, [chain_id], clip_ids, groups_id, name
            )
        master = self._register_uid(master_element, master_uid)
        item_uid = str(uuid.uuid4())
        item_element = self._register_uid(
            new_clip_item(item_uid, master_uid, name, label_index), item_uid
        )
        return self._attach_imported_item(
            item_element, item_uid, master, clip_ids, file_path, duration
        )

    def _make_primary_media(self, path: Path, own_rect: str) -> tuple[str, str]:
        """Synthesize a plain (non-proxy) `Media` + `VideoStream` pair.

        Returns `(uid, frame rect)`. Used when a hi-res attach replaces
        what the item plays: the newcomer becomes the media, so it carries
        no `IsProxy` and no frame-rect override. `own_rect` is the
        incumbent's raster, which the newcomer's aspect has to match.
        """
        probe = _probe_movie(path)
        if probe.audio is not None:
            raise NotImplementedError(
                "audio-carrying replacement media is not supported (no reference)"
            )
        rect = f"0,0,{probe.width},{probe.height}"
        # Refuse BEFORE registering anything, the way `_make_proxy_media`
        # does - a rejected attach must leave the project untouched.
        _check_proxy_aspect(own_rect, rect)
        stream = new_movie_video_stream(
            probe.width,
            probe.height,
            probe.frame_rate,
            probe.duration,
            probe.container,
            probe.codec,
            probe.field_order,
        )
        stream_id = self._register(stream)
        media_uid = str(uuid.uuid4())
        media = new_movie_media(
            media_uid,
            stream_id,
            str(path.resolve()),
            str(self._path.resolve().parent),
            str(uuid.uuid4()),
            None,
            None,
            probe.start_ticks,
        )
        self._imported_media.append(self._register_uid(media, media_uid))
        return media_uid, rect

    def _make_proxy_media(self, path: Path, hires_rect: str) -> str:
        """Synthesize a proxy's `Media` + `VideoStream` pair; returns the UID.

        The stream mirrors an import of the proxy file, plus the HI-RES
        frame rect carried as an override so the item keeps reporting the
        original raster; the `Media` gains `IsProxy` as its last child
        (18_proxy). Premiere refuses a proxy whose frame aspect ratio
        differs from the source's, and so does py.
        """
        probe = _probe_movie(path)
        if probe.audio is not None:
            raise NotImplementedError(
                "audio-carrying proxy media is not supported (no reference)"
            )
        parts = hires_rect.split(",")
        hires_width, hires_height = int(parts[2]), int(parts[3])
        if probe.width * hires_height != hires_width * probe.height:
            raise ValueError(
                "proxy frame aspect ratio must match the source "
                f"({probe.width}x{probe.height} vs {hires_width}x{hires_height})"
            )
        stream = new_movie_video_stream(
            probe.width,
            probe.height,
            probe.frame_rate,
            probe.duration,
            probe.container,
            probe.codec,
            probe.field_order,
        )
        # The override pair slots after OriginalColorSpace (before AlphaType
        # where the codec profile writes one).
        children = [child.tag for child in stream]
        anchor = children.index("OriginalColorSpace")
        if anchor + 1 < len(children):
            following = children[anchor + 1]
            insert_leaf_before(stream, following, "IsFrameRectOverridden", "true")
            insert_leaf_before(stream, following, "OverriddenFrameRect", hires_rect)
        else:
            append_leaf(stream, "IsFrameRectOverridden", "true")
            append_leaf(stream, "OverriddenFrameRect", hires_rect)
        stream_id = self._register(stream)
        media_uid = str(uuid.uuid4())
        media = new_movie_media(
            media_uid,
            stream_id,
            str(path.resolve()),
            str(self._path.resolve().parent),
            str(uuid.uuid4()),
            None,
            None,
            probe.start_ticks,
        )
        append_leaf(media, "IsProxy", "true")
        self._imported_media.append(self._register_uid(media, media_uid))
        return media_uid

    def _import_audio(self, path: Path, preferences: Preferences | None) -> ProjectItem:
        _validate_media_path(path)
        suffix = path.suffix.lower()
        data = path.read_bytes()
        info = read_audio_info(data, suffix)
        if suffix in COMPRESSED_SAMPLE_TYPES:
            # A compressed codec's sample type is fixed by what it decodes to.
            sample_type: str | None = COMPRESSED_SAMPLE_TYPES[suffix]
        else:
            sample_types = PCM_SAMPLE_TYPES[suffix]
            sample_format = (info.format_tag, info.sample_width)
            if sample_format not in sample_types:
                raise NotImplementedError(
                    f"unsupported {suffix} sample format: tag {info.format_tag}, "
                    f"{info.sample_width * 8}-bit"
                )
            sample_type = sample_types[sample_format]
        frame_rate = TICKS_PER_SECOND // info.sample_rate
        duration = info.frames * frame_rate
        label_index, label_color = self._label(
            preferences, "Audio", AUDIO_LABEL_INDEX, AUDIO_LABEL_COLOR
        )

        # Mono, stereo and 5.1 are native channel types, carried by ONE source
        # clip. Premiere imports any other channel count as one MONO source
        # clip per channel - a 4-channel file lands four full media graphs -
        # so both the source clips and the channel groups are lists here.
        # Each group entry maps a channel to its (source clip, channel).
        channel_type = CHANNEL_TYPES.get(info.channels)
        if channel_type is None:
            clip_channel_counts = [1] * info.channels
            groups = [
                (CHANNEL_TYPES[1], [(index, 0)]) for index in range(info.channels)
            ]
        else:
            clip_channel_counts = [info.channels]
            groups = [
                (channel_type, [(0, channel) for channel in range(info.channels)])
            ]

        name = path.name
        file_path = str(path.resolve())
        project_dir = str(self._path.resolve().parent)
        content_state = str(uuid.uuid4())
        # One file identity shared by every Media object describing this file.
        file_key = str(uuid.uuid4())
        binary_hash = str(uuid.uuid4())
        logging_id = self._register(
            new_media_logging_info(name, frame_rate, duration, "1", "200", 0)
        )
        chain_ids = []
        clip_ids = []
        for stream_number, channels in enumerate(clip_channel_counts):
            layout = audio_channel_layout(channels)
            stream_id = self._register(
                new_audio_stream(frame_rate, layout, duration, sample_type)
            )
            media_uid = str(uuid.uuid4())
            self._imported_media.append(
                self._register_uid(
                    new_audio_media(
                        media_uid,
                        stream_id,
                        file_path,
                        project_dir,
                        content_state,
                        frame_rate,
                        file_key,
                        binary_hash,
                        stream_number,
                    ),
                    media_uid,
                )
            )
            source_id = self._register(new_audio_media_source(media_uid, duration))
            secondary_ids = [
                self._register(new_secondary_content(source_id, channel))
                for channel in range(channels)
            ]
            markers_id = self._register(new_markers_collection(content_state))
            chain_ids.append(
                self._register(
                    new_master_audio_chain(channels, CHANNEL_TYPES[channels])
                )
            )
            clip_ids.append(
                self._register(
                    new_audio_template_clip(
                        markers_id,
                        source_id,
                        secondary_ids,
                        label_index,
                        label_color,
                        layout,
                    )
                )
            )
        vector_ids = []
        for group_type, group_channels in groups:
            channel_ids = [
                self._register(new_channel_serializer(source_clip, channel))
                for source_clip, channel in group_channels
            ]
            vector_ids.append(
                self._register(new_channel_vector(channel_ids, group_type))
            )
        groups_id = self._register(new_audio_channel_groups(vector_ids))
        master_uid = str(uuid.uuid4())
        master = self._register_uid(
            new_audio_master_clip(
                master_uid, logging_id, chain_ids, clip_ids, groups_id, name
            ),
            master_uid,
        )
        item_uid = str(uuid.uuid4())
        item_element = self._register_uid(
            new_clip_item(item_uid, master_uid, name, label_index), item_uid
        )
        return self._attach_imported_item(
            item_element, item_uid, master, clip_ids, file_path, duration
        )

    def _import_still(self, path: Path, preferences: Preferences | None) -> ProjectItem:
        _validate_media_path(path)
        suffix = path.suffix.lower()
        if suffix not in STILL_CODECS:
            raise NotImplementedError(
                "supported stills are " + ", ".join(sorted(STILL_CODECS))
            )
        profile = STILL_CODECS[suffix]
        data = path.read_bytes()
        if suffix == ".bmp":
            width, height = read_bmp_size(data)
        elif suffix == ".jpg":
            width, height = read_jpeg_size(data)
        elif suffix == ".gif":
            width, height = read_gif_size(data)
        elif suffix == ".psd":
            width, height = read_psd_size(data)
        elif suffix == ".tif":
            width, height = read_tiff_size(data)
        else:
            width, height = read_png_size(data)
        frame_rate = STILL_FRAME_RATE
        out_ticks = STILL_DEFAULT_OUT
        if preferences is not None:
            frame_rate = preferences.still_frame_rate or frame_rate
            out_ticks = preferences.still_default_out_ticks or out_ticks
        label_index, label_color = self._label(
            preferences, "Still", STILL_LABEL_INDEX, STILL_LABEL_COLOR
        )

        name = path.name
        file_path = str(path.resolve())
        content_state = str(uuid.uuid4())
        stream_id = self._register(new_video_stream(width, height, frame_rate, profile))
        media_uid = str(uuid.uuid4())
        self._imported_media.append(
            self._register_uid(
                new_media(
                    media_uid,
                    stream_id,
                    file_path,
                    str(self._path.resolve().parent),
                    content_state,
                ),
                media_uid,
            )
        )
        source_id = self._register(new_media_source(media_uid))
        markers_id = self._register(new_markers_collection(content_state))
        logging_id = self._register(new_logging_info(name, frame_rate))
        groups_id = self._register(new_channel_groups())
        clip_id = self._register(
            new_template_clip(
                markers_id, source_id, label_index, label_color, out_ticks
            )
        )
        master_uid = str(uuid.uuid4())
        master = self._register_uid(
            new_master_clip(master_uid, logging_id, clip_id, groups_id, name),
            master_uid,
        )
        item_uid = str(uuid.uuid4())
        item_element = self._register_uid(
            new_clip_item(item_uid, master_uid, name, label_index), item_uid
        )
        return self._attach_imported_item(
            item_element, item_uid, master, [clip_id], file_path, STILL_DURATION
        )

    def save(self, path: str | Path) -> None:
        """Save the project to a new file.

        Like ExtendScript's `saveAs`, the project then reports the new
        `name` and `path`. Media imported in this session has its stored
        relative path re-derived against the destination directory, the way
        Premiere recomputes it on every save.

        Refuses to overwrite an existing file (`FileExistsError`). The
        write is atomic: bytes go to a temporary sibling file which then
        replaces the target.
        """
        _validate_save_path(path)
        self._write(Path(path))

    def save_in_place(self) -> None:
        """Overwrite the file this project was parsed from.

        ExtendScript's `save()`, as distinct from its `saveAs(path)` -
        which is what `save(path)` is here, and which refuses to
        overwrite. This one overwrites deliberately, so it is a separate
        call rather than a flag.
        """
        if not self._path.exists():
            raise FileNotFoundError(
                f"project has no saved file to overwrite: {self._path}"
            )
        self._write(self._path)

    def _write(self, target: Path) -> None:
        # Atomic: bytes go to a temporary sibling which then replaces the
        # target, so a failed write cannot truncate an existing project.
        self._rewrite_relative_paths(target.resolve().parent)
        data = self._document.to_bytes()
        temporary = target.with_name(target.name + ".tmp")
        temporary.write_bytes(data)
        os.replace(temporary, target)
        self._path = target

    def _rewrite_relative_paths(self, directory: Path) -> None:
        # Only media THIS session imported: an untouched parsed document must
        # still save byte-identically, so nothing already in the file is
        # rewritten (Premiere refreshes those itself on open).
        for media in self._imported_media:
            relative = media.find("RelativePath")
            actual = media.findtext("FilePath")
            if relative is None or not actual:
                continue
            try:
                relative.text = os.path.relpath(actual, str(directory))
            except ValueError:
                # A different drive has no relative form; the absolute
                # FilePath still resolves.
                continue

    def __repr__(self) -> str:
        return f"Project(name={self.name!r}, {len(self._sequences)} sequence(s))"

Attributes

active_sequence property

active_sequence

The frontmost sequence, if any. Read-only.

Best-effort: Premiere stores no dedicated key; this derives from the open-sequence list and can differ from the session state.

document_id property

document_id

The project's persistent identifier. Read-only.

name property

name

The project file name. Read-only.

path property

path

The project file path. Read-only.

root_item property

root_item

The root of the item tree. Read-only.

sequences property

sequences

The project's sequences, indexable by name. Read-only.

Methods:

__init__

__init__(_document, path)
Source code in src/py_premiere/models/project.py
def __init__(self, _document: PremiereDocument, path: Path) -> None:
    self._document = _document
    self._path = path
    self._root_item: ProjectItem | None = None
    self._sequences: list[Sequence] = []
    self._active_sequence: Sequence | None = None
    self._items_by_master_uid: dict[str, ProjectItem] | None = None
    self._items_by_sequence_uid: dict[str, ProjectItem] | None = None
    #: The bin `import_files` is currently importing into, if any.
    self._import_target: ProjectItem | None = None
    #: Overrides the machine-profile discovery of `Preferences` when set
    #: (threaded from `parse(preferences_path=...)` / `new(...)`).
    self._preferences_path: Path | None = None
    #: `Media` objects synthesized by `import_files`, whose RelativePath
    #: is re-derived at save time against the destination directory.
    self._imported_media: list[ET.Element] = []

__repr__

__repr__()
Source code in src/py_premiere/models/project.py
def __repr__(self) -> str:
    return f"Project(name={self.name!r}, {len(self._sequences)} sequence(s))"

add_sequence

add_sequence(
    name, preset=DEFAULT_PRESET, video_tracks=None
)

Create a new sequence and return it.

Builds the object graph Premiere writes for a new sequence - its tracks plus the audio mix graph behind them - and splices it in with fresh identifiers. preset selects the format; see sequence_presets() for the names (default 1080p 23.976 fps).

The track layout follows the preset: 3 video tracks throughout, and 4 stereo audio tracks except on the mono-discrete broadcast presets, where broadcast8mono builds 8.

video_tracks overrides the preset's video track count. Every preset Adobe ships asks for 3, so a different count is only reachable this way; it is checked against sequences Premiere built from .sqpreset files asking for 1 and 5.

A sequence Premiere derives from a clip (createNewSequenceFromClips) has 3 audio tracks instead; only the default layout is built here.

Source code in src/py_premiere/models/project.py
def add_sequence(
    self,
    name: str,
    preset: str = DEFAULT_PRESET,
    video_tracks: int | None = None,
) -> Sequence:
    """Create a new sequence and return it.

    Builds the object graph Premiere writes for a new sequence - its
    tracks plus the audio mix graph behind them - and splices it in with
    fresh identifiers. `preset` selects the format; see
    `sequence_presets()` for the names (default 1080p 23.976 fps).

    The track layout follows the preset: 3 video tracks throughout, and
    4 stereo audio tracks except on the mono-discrete broadcast presets,
    where `broadcast8mono` builds 8.

    `video_tracks` overrides the preset's video track count. Every preset
    Adobe ships asks for 3, so a different count is only reachable this
    way; it is checked against sequences Premiere built from `.sqpreset`
    files asking for 1 and 5.

    A sequence Premiere derives from a clip
    (`createNewSequenceFromClips`) has 3 audio tracks instead; only the
    default layout is built here.
    """
    _validate_sequence_name(name)
    if video_tracks is not None:
        validate_track_count(video_tracks)
    document = self._document
    fragment = build_sequence(name, preset, video_tracks=video_tracks)
    spliced = self._splice_fragment(fragment)
    sequence_element = spliced.get("Sequence")
    item_element = spliced.get("ClipProjectItem")
    master_element = spliced.get("MasterClip")
    if sequence_element is None or item_element is None or master_element is None:
        raise ValueError("sequence template is missing its core objects")

    root_item = self.root_item
    if root_item is None:
        raise ValueError("project has no root item")
    container = root_item._element.find("ProjectItemContainer")
    if container is None:
        raise ValueError("root item has no ProjectItemContainer")
    item_uid = item_element.get("ObjectUID")
    if item_uid is None:
        raise ValueError("template panel item has no ObjectUID")
    _add_item_ref(_child_items(container), item_uid)

    # Model -> parser is a proven circular dependency; parsers import
    # the models package at module load.
    from ..parsers.sequence import parse_sequence

    sequence = parse_sequence(document, self, sequence_element)
    self._sequences.append(sequence)
    item = ProjectItem(item_element, self, ProjectItemType.CLIP)
    item._parent = root_item
    item._master_element = master_element
    item._clip_elements = [
        document.resolve(ref) for ref in master_element.findall("Clips/Clip")
    ]
    item._sequence_uid = sequence_element.get("ObjectUID")
    root_item._children.append(item)
    self._items_by_master_uid = None
    self._items_by_sequence_uid = None
    return sequence

create_merged_clip

create_merged_clip(video_item, audio_item, name=None)

Merge a video clip with an audio clip and return the panel item.

Premiere has no scripting API for Merge Clips; this synthesizes the graph its UI writes (23_merged_clip): a hidden sequence flagged BE.Sequence.IsMergedClip holding PRIVATE COPIES of both source graphs on one video and one audio track, a Link binding the two placements, and a panel item whose master plays that sequence through Video/AudioSequenceSource. The sources are left untouched; the copies are synthesized the way an import is (the media files must still be readable) and then take over the sources' file identity, like subclips do.

name defaults to Premiere's own: the video clip's name plus " - Merged". The fixture-verified shapes are supported: a video-only movie plus one mono or stereo audio clip; the audio lands as one MONO track per channel, the way Premiere splits it (77_merged_stereo).

Source code in src/py_premiere/models/project.py
def create_merged_clip(
    self,
    video_item: ProjectItem,
    audio_item: ProjectItem,
    name: str | None = None,
) -> ProjectItem:
    """Merge a video clip with an audio clip and return the panel item.

    Premiere has no scripting API for `Merge Clips`; this synthesizes
    the graph its UI writes (23_merged_clip): a hidden sequence flagged
    `BE.Sequence.IsMergedClip` holding PRIVATE COPIES of both source
    graphs on one video and one audio track, a `Link` binding the two
    placements, and a panel item whose master plays that sequence
    through `Video/AudioSequenceSource`. The sources are left
    untouched; the copies are synthesized the way an import is (the
    media files must still be readable) and then take over the sources'
    file identity, like subclips do.

    `name` defaults to Premiere's own: the video clip's name plus
    `" - Merged"`. The fixture-verified shapes are supported: a
    video-only movie plus one mono or stereo audio clip; the audio
    lands as one MONO track per channel, the way Premiere splits it
    (77_merged_stereo).
    """
    if name is not None:
        _validate_item_name(name)
    video_path = video_item.media_path
    audio_path = audio_item.media_path
    if video_path is None or audio_path is None:
        raise ValueError("item has no media file to merge")
    for item, wanted in ((video_item, "VideoClip"), (audio_item, "AudioClip")):
        if item._type is not ProjectItemType.CLIP or item.is_sequence:
            raise ValueError("merged clips are built from media clip items")
        if [element.tag for element in item._clip_elements] != [wanted]:
            raise ValueError(
                "only a video-only movie plus an audio-only clip can be merged"
            )
    layout = audio_item._clip_elements[0].findtext("AudioChannelLayout")
    if layout == MONO_LAYOUT:
        channels = 1
    elif layout == audio_channel_layout(2):
        channels = 2
    else:
        raise ValueError("only mono or stereo audio is supported in a merged clip")
    document = self._document
    merged_name = name if name is not None else f"{video_item.name} - Merged"

    # Private copies of both source graphs, renamed and stamped the way
    # Premiere's merge does. The MergeClipUtils bag keeps each source's
    # original master name; the duplicated masters and their logging
    # take the merged name.
    video_copy = self.import_files([video_path])[0]
    audio_copy = self.import_files([audio_path])[0]
    copies = (
        (
            video_item,
            video_copy,
            [
                (
                    "MZ.MergeClipUtils.ComponentMasterClipOriginalName",
                    video_item.name,
                ),
            ],
        ),
        (
            audio_item,
            audio_copy,
            [
                ("MZ.MergeClipUtils.AudioTrackNumberFromOriginalMergedClip", "0"),
                (
                    "MZ.MergeClipUtils.ComponentMasterClipOriginalName",
                    audio_item.name,
                ),
            ],
        ),
    )
    for source, copy, bag in copies:
        master = copy._master_element
        if master is None:
            raise ValueError("synthesized copy has no master clip")
        master.insert(0, build_master_bag(bag))
        name_element = master.find("Name")
        if name_element is not None:
            name_element.text = merged_name
        logging_ref = master.find("LoggingInfo")
        if logging_ref is not None:
            clip_name = document.resolve(logging_ref).find("ClipName")
            if clip_name is not None:
                clip_name.text = merged_name
        # A fresh audio import carries a DefMappingID; the duplicated
        # master inside a merged clip does not (23_merged_clip).
        mapping = master.find("DefMappingID")
        if mapping is not None:
            remove_child(master, mapping)
        source._share_file_identity(copy)

    if channels > 1:
        # Premiere's merge splits the audio into per-channel MONO groups
        # over the ONE copied source (77_merged_stereo): the copy keeps
        # its stereo stream and template clip, but its master carries a
        # bag-less mono chain and a single-channel vector per channel
        # instead of the import's stereo pair.
        audio_master = audio_copy._master_element
        if audio_master is None:
            raise ValueError("synthesized copy has no master clip")
        chains_list = audio_master.find("AudioComponentChains")
        groups_ref = audio_master.find("AudioClipChannelGroups")
        if chains_list is None or groups_ref is None:
            raise ValueError("audio copy has no channel plumbing")
        for entry in chains_list.findall("AudioComponentChain"):
            document.remove_object(document.resolve(entry))
        for entry in list(chains_list):
            chains_list.remove(entry)
        _indexed_refs(
            chains_list,
            "AudioComponentChain",
            [self._register(new_master_audio_chain(1, 0)) for _ in range(channels)],
        )
        vectors_list = document.resolve(groups_ref).find("ClipChannelVectors")
        if vectors_list is None:
            raise ValueError("audio copy has no channel vectors")
        for entry in vectors_list.findall("ClipChannelVectorItem"):
            vector = document.resolve(entry)
            for channel_entry in vector.findall("ClipChannels/ClipChannelItem"):
                document.remove_object(document.resolve(channel_entry))
            document.remove_object(vector)
        for entry in list(vectors_list):
            vectors_list.remove(entry)
        mono_vector_ids = []
        for channel in range(channels):
            serializer_id = self._register(new_channel_serializer(0, channel))
            mono_vector_ids.append(
                self._register(new_channel_vector([serializer_id], 0))
            )
        _indexed_refs(vectors_list, "ClipChannelVectorItem", mono_vector_ids)

    video_media = video_copy._media_element()
    audio_media = audio_copy._media_element()
    if video_media is None or audio_media is None:
        raise ValueError("synthesized copy has no media")
    video_stream_ref = video_media.find("VideoStream")
    audio_stream_ref = audio_media.find("AudioStream")
    if video_stream_ref is None or audio_stream_ref is None:
        raise ValueError("synthesized copy has no media stream")
    video_stream = document.resolve(video_stream_ref)
    audio_stream = document.resolve(audio_stream_ref)
    timebase = int(video_stream.findtext("FrameRate") or 0)
    audio_rate = int(audio_stream.findtext("FrameRate") or 0)
    video_duration = int(video_stream.findtext("Duration") or 0)
    audio_duration = int(audio_stream.findtext("Duration") or 0)
    rect = (video_stream.findtext("FrameRect") or "0,0,0,0").split(",")
    width, height = int(rect[2]), int(rect[3])
    # Both placements land on whole video frames (`add_clip` floors
    # them); the sequence spans the longer one.
    end_ticks = max(
        video_duration - video_duration % timebase,
        audio_duration - audio_duration % timebase,
    )
    display_format = TIMECODE_FORMATS[(timebase, False)]

    # The hidden sequence: one video track, one audio track per source
    # channel, the audio mix graph behind them, and the merged-clip
    # property bag.
    sequence_uid = str(uuid.uuid4())
    video_track_uid = str(uuid.uuid4())
    audio_track_uids = [str(uuid.uuid4()) for _ in range(channels)]
    group_chain_id = self._register(build_empty_video_chain())
    track_chain_ids = []
    track_pan_ids = []
    for _ in range(channels):
        volume_id = self._register(build_volume_param())
        mute_id = self._register(build_mute_param())
        fader_id = self._register(build_fader(audio_rate, [volume_id, mute_id]))
        meter_id = self._register(build_meter(audio_rate))
        track_chain_ids.append(
            self._register(build_fader_chain(fader_id, meter_id))
        )
        track_pan_ids.append(self._register(build_pan_processor(audio_rate)))
    mix_volume_id = self._register(build_volume_param())
    mix_mute_id = self._register(build_mute_param())
    mix_fader_id = self._register(
        build_fader(audio_rate, [mix_volume_id, mix_mute_id])
    )
    mix_meter_id = self._register(build_meter(audio_rate))
    mix_chain_id = self._register(build_fader_chain(mix_fader_id, mix_meter_id))
    mix_pan_id = self._register(build_pan_processor(audio_rate))
    inlet_id = self._register(build_inlet(audio_track_uids))
    mix_track_id = self._register(
        build_mix_track(mix_chain_id, mix_pan_id, inlet_id)
    )
    video_group_id = self._register(
        build_video_group(video_track_uid, timebase, width, height, group_chain_id)
    )
    audio_group_id = self._register(
        build_audio_group(audio_track_uids, audio_rate, mix_track_id)
    )
    data_group_id = self._register(build_data_group(timebase))
    document.attach_object(build_video_track(video_track_uid))
    for index, track_uid in enumerate(audio_track_uids):
        document.attach_object(
            build_audio_track(
                track_uid,
                track_chain_ids[index],
                track_pan_ids[index],
                track_id=2 + index,
                index=index,
            )
        )
    sequence_element = document.attach_object(
        build_merged_sequence(
            sequence_uid,
            merged_name,
            display_format,
            end_ticks,
            width,
            height,
            video_group_id,
            audio_group_id,
            data_group_id,
        )
    )

    # Model -> parser is a proven circular dependency; parsers import
    # the models package at module load.
    from ..parsers.sequence import parse_sequence

    sequence = parse_sequence(document, self, sequence_element)
    self._sequences.append(sequence)
    video_placed = sequence._video_tracks[0].add_clip(video_copy)
    audio_placed_ids = []
    for channel, track in enumerate(sequence._audio_tracks):
        placed = track.add_clip(audio_copy)
        self._narrow_merged_channel(placed, channel)
        audio_placed_ids.append(placed._element.get("ObjectID") or "")

    link_id = self._register(
        build_link([video_placed._element.get("ObjectID") or "", *audio_placed_ids])
    )
    link_entry = sequence_element.find(
        "PersistentGroupContainer/LinkContainer/Links/Link"
    )
    if link_entry is None:
        raise ValueError("merged sequence has no link entry")
    link_entry.set("ObjectRef", link_id)

    # The panel-facing graph: the master's template clips play the
    # hidden sequence through Video/AudioSequenceSource.
    video_source_id = self._register(
        build_sequence_source("VideoSequenceSource", sequence_uid, end_ticks)
    )
    audio_source_id = self._register(
        build_sequence_source("AudioSequenceSource", sequence_uid, end_ticks)
    )
    secondary_id = self._register(new_secondary_content(audio_source_id, 0))
    # One mono chain and one single-channel vector per source channel;
    # the panel-side vectors index the hidden sequence's audio TRACKS
    # (source clip i, channel 0), unlike the copy's (source 0,
    # channel i) - both shapes straight from 77_merged_stereo.
    panel_vector_ids = []
    for index in range(channels):
        channel_id = self._register(new_channel_serializer(index, 0))
        panel_vector_ids.append(self._register(new_channel_vector([channel_id], 0)))
    groups_id = self._register(new_audio_channel_groups(panel_vector_ids))
    master_chain_ids = [
        self._register(new_master_audio_chain(1, 0)) for _ in range(channels)
    ]
    audio_template_id = self._register(
        build_panel_audio_template(audio_source_id, secondary_id)
    )
    video_template_id = self._register(build_panel_video_template(video_source_id))
    logging_id = self._register(
        build_panel_logging(video_item.name, display_format, end_ticks, timebase)
    )
    master_uid = str(uuid.uuid4())
    master_element = document.attach_object(
        build_panel_master(
            master_uid,
            logging_id,
            master_chain_ids,
            audio_template_id,
            video_template_id,
            groups_id,
            merged_name,
        )
    )
    item_uid = str(uuid.uuid4())
    item_element = document.attach_object(
        build_panel_item(item_uid, master_uid, merged_name)
    )

    root_item = self.root_item
    if root_item is None:
        raise ValueError("project has no root item")
    container = root_item._element.find("ProjectItemContainer")
    if container is None:
        raise ValueError("root item has no ProjectItemContainer")
    # The duplicated source graphs stay, but their panel items go: the
    # copies are reachable only through the hidden sequence.
    for copy in (video_copy, audio_copy):
        _detach_item_ref(container, copy._element.get("ObjectUID") or "")
        document.remove_object(copy._element)
        root_item._children.remove(copy)
    _add_item_ref(_child_items(container), item_uid)
    item = ProjectItem(item_element, self, ProjectItemType.CLIP)
    item._parent = root_item
    item._master_element = master_element
    item._clip_elements = [
        document.by_object_id[audio_template_id],
        document.by_object_id[video_template_id],
    ]
    item._default_out_ticks = end_ticks
    item._sequence_uid = sequence_uid
    root_item._children.append(item)
    self._items_by_master_uid = None
    self._items_by_sequence_uid = None
    return item

create_multicam_clip

create_multicam_clip(items, name=None)

Build a multicam source clip from angle items and return it.

Premiere has no scripting API for Create Multi-Camera Source Sequence; this synthesizes the graph its UI writes (24_multicam): a hidden sequence with one video track per angle and a 32-channel adaptive audio bus, a master flagged Source.Monitor.Multicam.Enabled, and the source items filed into a new Processed Clips bin. Unlike a merged clip nothing is copied - the placements play the ORIGINAL source graphs.

items are the angles in track order. name defaults to Premiere's own: the first angle's name plus Multicam. The fixture-verified shapes are supported: two or more angles of which exactly one carries (stereo) audio - either an AV movie among the video angles (its halves come out linked, 24_multicam) or an audio-only clip (audio track only, no link, 79_two_multicams). Cameras are synchronized at their starts (in-point sync) and every angle keeps its full length (78_multicam_3angle).

Source code in src/py_premiere/models/project.py
def create_multicam_clip(
    self,
    items: list[ProjectItem],
    name: str | None = None,
) -> ProjectItem:
    """Build a multicam source clip from angle items and return it.

    Premiere has no scripting API for `Create Multi-Camera Source
    Sequence`; this synthesizes the graph its UI writes (24_multicam):
    a hidden sequence with one video track per angle and a 32-channel
    adaptive audio bus, a master flagged
    `Source.Monitor.Multicam.Enabled`, and the source items filed into
    a new `Processed Clips` bin. Unlike a merged clip nothing is
    copied - the placements play the ORIGINAL source graphs.

    `items` are the angles in track order. `name` defaults to
    Premiere's own: the first angle's name plus `Multicam`. The
    fixture-verified shapes are supported: two or more angles of which
    exactly one carries (stereo) audio - either an AV movie among the
    video angles (its halves come out linked, 24_multicam) or an
    audio-only clip (audio track only, no link, 79_two_multicams).
    Cameras are synchronized at their starts (in-point sync) and every
    angle keeps its full length (78_multicam_3angle).
    """
    if name is not None:
        _validate_item_name(name)
    if len(items) < 2:
        raise ValueError("a multicam clip needs at least two angles")
    audio_angle: ProjectItem | None = None
    video_items = []
    for item in items:
        if item._type is not ProjectItemType.CLIP or item.is_sequence:
            raise ValueError("multicam angles must be media clip items")
        if item.media_path is None:
            raise ValueError("angle has no media file")
        tags = sorted(element.tag for element in item._clip_elements)
        if tags == ["VideoClip"]:
            video_items.append(item)
            continue
        if audio_angle is not None:
            raise ValueError("only one angle may carry audio")
        if tags == ["AudioClip", "VideoClip"]:
            audio_angle = item
            video_items.append(item)
        elif tags == ["AudioClip"]:
            audio_angle = item
        else:
            raise ValueError("multicam angles must be movie or audio clips")
    if audio_angle is None:
        raise ValueError("one angle must carry the audio track")
    if not video_items:
        raise ValueError("a multicam clip needs at least one video angle")
    audio_template = next(
        element
        for element in audio_angle._clip_elements
        if element.tag == "AudioClip"
    )
    if audio_template.findtext("AudioChannelLayout") != audio_channel_layout(2):
        raise ValueError("only a stereo audio angle is supported")
    audio_is_linked = audio_angle in video_items
    document = self._document
    multicam_name = name if name is not None else f"{items[0].name}Multicam"

    # Geometry and display format follow the first video angle; the
    # sequence sources span the longest angle (whole video frames, as
    # the placements land - 78's short angle keeps its own length).
    timebase = 0
    width = height = 0
    end_ticks = 0
    for index, item in enumerate(video_items):
        media = item._media_element()
        stream_ref = None if media is None else media.find("VideoStream")
        if stream_ref is None:
            raise ValueError("angle has no video stream")
        stream = document.resolve(stream_ref)
        rate = int(stream.findtext("FrameRate") or 0)
        duration = int(stream.findtext("Duration") or 0)
        if index == 0:
            timebase = rate
            rect = (stream.findtext("FrameRect") or "0,0,0,0").split(",")
            width, height = int(rect[2]), int(rect[3])
        end_ticks = max(end_ticks, duration - duration % timebase)
    display_format = TIMECODE_FORMATS[(timebase, False)]

    fragment = build_multicam(
        multicam_name,
        timebase,
        width,
        height,
        display_format,
        end_ticks,
        len(video_items),
        link=audio_is_linked,
    )
    spliced = self._splice_fragment(fragment)
    sequence_element = spliced.get("Sequence")
    item_element = spliced.get("ClipProjectItem")
    master_element = spliced.get("MasterClip")
    if sequence_element is None or item_element is None or master_element is None:
        raise ValueError("multicam fragment is missing its core objects")

    # Model -> parser is a proven circular dependency; parsers import
    # the models package at module load.
    from ..parsers.sequence import parse_sequence

    sequence = parse_sequence(document, self, sequence_element)
    self._sequences.append(sequence)
    av_video_placed: TrackItem | None = None
    for index, angle in enumerate(video_items):
        placed = sequence._video_tracks[index].add_clip(angle)
        if angle is audio_angle:
            av_video_placed = placed
    audio_placed = sequence._audio_tracks[0].add_clip(audio_angle)
    # The multicam placement's audio chain is the bag-less stereo
    # DefaultVol shape (24_multicam), not the MZ.ActiveComponent-bagged
    # mono chain of a normal timeline placement.
    chain_ref = audio_placed._element.find(
        "ClipTrackItem/ComponentOwner/Components"
    )
    if chain_ref is not None:
        chain = document.resolve(chain_ref)
        chain_inner = chain.find("ComponentChain")
        node = None if chain_inner is None else chain_inner.find("Node")
        if chain_inner is not None and node is not None:
            chain_inner.remove(node)
            chain_inner.text = "\n\t\t"
        insert_leaf_before(
            chain, "ComponentChain", "DefaultChannelVolumeComponentID", "2"
        )
        append_leaf(chain, "AudioChannelLayout", audio_channel_layout(2))
        append_leaf(chain, "ChannelType", "1")

    if av_video_placed is not None:
        # The Link binds the audio placement to its own angle's video
        # placement, audio first (24_multicam); an audio-only angle has
        # no video half, and Premiere writes no Link at all.
        link_id = self._register(
            build_link(
                [
                    audio_placed._element.get("ObjectID") or "",
                    av_video_placed._element.get("ObjectID") or "",
                ]
            )
        )
        link_entry = sequence_element.find(
            "PersistentGroupContainer/LinkContainer/Links/Link"
        )
        if link_entry is None:
            raise ValueError("multicam sequence has no link entry")
        link_entry.set("ObjectRef", link_id)

    # File the source angles into the `Processed Clips` bin, as
    # Premiere does - reusing an existing root-level one
    # (79_two_multicams) or creating it.
    root_item = self.root_item
    if root_item is None:
        raise ValueError("project has no root item")
    root_container = root_item._element.find("ProjectItemContainer")
    if root_container is None:
        raise ValueError("root item has no ProjectItemContainer")
    bin_item = self._root_bin("Processed Clips", build_processed_clips_bin)
    bin_container = item_container(bin_item._element)
    if bin_container is None:
        raise ValueError("bin has no ProjectItemContainer")
    for angle in items:
        if angle._parent is bin_item:
            continue
        parent = angle._parent
        container = None if parent is None else item_container(parent._element)
        angle_uid = angle._element.get("ObjectUID") or ""
        if parent is not None and container is not None:
            _detach_item_ref(container, angle_uid)
            parent._children.remove(angle)
        _add_item_ref(_child_items(bin_container), angle_uid)
        angle._parent = bin_item
        bin_item._children.append(angle)

    item_uid = item_element.get("ObjectUID") or ""
    _add_item_ref(_child_items(root_container), item_uid)
    item = ProjectItem(item_element, self, ProjectItemType.CLIP)
    item._parent = root_item
    item._master_element = master_element
    item._clip_elements = [
        document.resolve(ref) for ref in master_element.findall("Clips/Clip")
    ]
    item._default_out_ticks = end_ticks
    item._sequence_uid = sequence_element.get("ObjectUID")
    root_item._children.append(item)
    self._items_by_master_uid = None
    self._items_by_sequence_uid = None
    return item

import_files

import_files(paths, target_bin=None)

Import media files into the project panel.

Synthesizes the same object graph Premiere writes for a fresh import. Supported so far: BMP/PNG/JPEG/GIF/TIFF/PSD stills; audio - WAV (16/24-bit PCM or 32-bit float, any channel count), AIFF, M4A and WMA; and video - uncompressed AVI, MJPEG-in-AVI, H.264/H.265/ProRes/DNxHR in MP4 and MOV (with or without an AAC audio track), MPEG-2 in MXF, and MP3/AAC. The content-state hashes Premiere stamps are change-detection caches, so py writes a fresh GUID and Premiere refreshes it on open. Premiere's auto-transcript bootstrap objects and its machine-local audio conform-cache paths are elided (regenerated on open).

target_bin is ExtendScript's importFiles parameter of the same name: the bin the new items land in, defaulting to the panel root.

Source code in src/py_premiere/models/project.py
def import_files(
    self, paths: list[str | Path], target_bin: ProjectItem | None = None
) -> list[ProjectItem]:
    """Import media files into the project panel.

    Synthesizes the same object graph Premiere writes for a fresh
    import. Supported so far: BMP/PNG/JPEG/GIF/TIFF/PSD stills; audio -
    WAV (16/24-bit PCM or 32-bit float, any channel count), AIFF, M4A and
    WMA; and video - uncompressed AVI, MJPEG-in-AVI,
    H.264/H.265/ProRes/DNxHR in MP4 and MOV (with or without an AAC
    audio track), MPEG-2 in MXF, and MP3/AAC. The content-state
    hashes Premiere stamps are change-detection caches, so py writes a
    fresh GUID and Premiere refreshes it on open. Premiere's
    auto-transcript bootstrap objects and its machine-local audio
    conform-cache paths are elided (regenerated on open).

    `target_bin` is ExtendScript's `importFiles` parameter of the same
    name: the bin the new items land in, defaulting to the panel root.
    """
    if target_bin is not None:
        if target_bin._type is not ProjectItemType.BIN:
            raise ValueError("target_bin must be a bin")
        if target_bin.project is not self:
            # The objects land in THIS document while the item reference
            # is written into the other one, so both come out broken: the
            # import is unreachable here and the donor gains a ref to an
            # ObjectUID it does not contain.
            raise ValueError("target_bin belongs to another project")
    # Import defaults come from the machine's own Premiere preferences
    # (matching what the user's Premiere would write); the factory
    # constants cover machines without one (e.g. CI). Read once per call:
    # locating and parsing the prefs file is not cheap.
    if self._preferences_path is not None:
        preferences: Preferences | None = Preferences(self._preferences_path)
    else:
        preferences = Preferences.load_default()
    items = []
    previous, self._import_target = self._import_target, target_bin
    try:
        for path in paths:
            path = Path(path)
            suffix = path.suffix.lower()
            if suffix in AUDIO_FORMATS:
                items.append(self._import_audio(path, preferences))
            elif suffix in MOVIE_SUFFIXES:
                items.append(self._import_video(path, preferences))
            elif suffix == ".srt":
                items.append(self._import_caption(path, preferences))
            else:
                items.append(self._import_still(path, preferences))
    finally:
        self._import_target = previous
    return items

save

save(path)

Save the project to a new file.

Like ExtendScript's saveAs, the project then reports the new name and path. Media imported in this session has its stored relative path re-derived against the destination directory, the way Premiere recomputes it on every save.

Refuses to overwrite an existing file (FileExistsError). The write is atomic: bytes go to a temporary sibling file which then replaces the target.

Source code in src/py_premiere/models/project.py
def save(self, path: str | Path) -> None:
    """Save the project to a new file.

    Like ExtendScript's `saveAs`, the project then reports the new
    `name` and `path`. Media imported in this session has its stored
    relative path re-derived against the destination directory, the way
    Premiere recomputes it on every save.

    Refuses to overwrite an existing file (`FileExistsError`). The
    write is atomic: bytes go to a temporary sibling file which then
    replaces the target.
    """
    _validate_save_path(path)
    self._write(Path(path))

save_in_place

save_in_place()

Overwrite the file this project was parsed from.

ExtendScript's save(), as distinct from its saveAs(path) - which is what save(path) is here, and which refuses to overwrite. This one overwrites deliberately, so it is a separate call rather than a flag.

Source code in src/py_premiere/models/project.py
def save_in_place(self) -> None:
    """Overwrite the file this project was parsed from.

    ExtendScript's `save()`, as distinct from its `saveAs(path)` -
    which is what `save(path)` is here, and which refuses to
    overwrite. This one overwrites deliberately, so it is a separate
    call rather than a flag.
    """
    if not self._path.exists():
        raise FileNotFoundError(
            f"project has no saved file to overwrite: {self._path}"
        )
    self._write(self._path)

sequence_presets staticmethod

sequence_presets()

The preset names accepted by add_sequence. Read-only.

Source code in src/py_premiere/models/project.py
@staticmethod
def sequence_presets() -> list[str]:
    """The preset names accepted by `add_sequence`. Read-only."""
    return sorted(FORMATS)