Initial commit
+9110 · 58 files changed
-
.gitignore+25 −0 -
CHANGELOG.md+10 −0 -
CONTRIBUTING.md+113 −0 -
COPYING.md+619 −0 -
README.md+56 −0 -
apidoc/conf.py+80 −0 -
apidoc/index.rst+17 −0 -
requirements.apidoc.txt+4 −0 -
requirements.dev.txt+3 −0 -
requirements.tests.txt+2 −0 -
requirements.txt+1 −0 -
scripts/run_mypy.py+27 −0 -
scripts/run_pytest.py+19 −0 -
scripts/run_sphinx-build.py+22 −0 -
setup.cfg+12 −0 -
spa_sequencer/__init__.py+47 −0 -
spa_sequencer/editorial/__init__.py+20 −0 -
spa_sequencer/editorial/core.py+64 −0 -
spa_sequencer/editorial/ops.py+288 −0 -
spa_sequencer/editorial/ui.py+50 −0 -
spa_sequencer/editorial/vse_io/__init__.py+26 −0 -
spa_sequencer/editorial/vse_io/core.py+114 −0 -
spa_sequencer/editorial/vse_io/ops.py+468 −0 -
spa_sequencer/editorial/vse_io/ui.py+23 −0 -
spa_sequencer/gpu_utils.py+99 −0 -
spa_sequencer/keymaps.py+65 −0 -
spa_sequencer/preferences.py+39 −0 -
spa_sequencer/render/__init__.py+20 −0 -
spa_sequencer/render/ops.py+463 −0 -
spa_sequencer/render/props.py+196 −0 -
spa_sequencer/render/tasks.py+517 −0 -
spa_sequencer/render/ui.py+47 −0 -
spa_sequencer/sequence/__init__.py+23 −0 -
spa_sequencer/sequence/ops.py+264 −0 -
spa_sequencer/sequence/overlay.py+365 −0 -
spa_sequencer/sequence/props.py+81 −0 -
spa_sequencer/sequence/ui.py+236 −0 -
spa_sequencer/shared_folders/__init__.py+20 −0 -
spa_sequencer/shared_folders/core.py+247 −0 -
spa_sequencer/shared_folders/ops.py+232 −0 -
spa_sequencer/shared_folders/ui.py+162 −0 -
spa_sequencer/shot/__init__.py+23 −0 -
spa_sequencer/shot/core.py+665 −0 -
spa_sequencer/shot/naming.py+378 −0 -
spa_sequencer/shot/ops.py+815 −0 -
spa_sequencer/shot/ui.py+62 −0 -
spa_sequencer/sync/__init__.py+24 −0 -
spa_sequencer/sync/core.py+670 −0 -
spa_sequencer/sync/ops.py+73 −0 -
spa_sequencer/sync/ui.py+60 −0 -
spa_sequencer/utils.py+107 −0 -
tests/conftest.py+31 −0 -
tests/test_addon.py+9 −0 -
tests/test_shared_folders.py+108 −0 -
tests/test_shot.py+415 −0 -
tests/test_shot_naming.py+127 −0 -
tests/test_sync.py+330 −0 -
tests/utils.py+27 −0
@@ -0,0 +1,25 @@ +# temporary files +*~ + +# vim +.*.swp + +# python +*.py[cod] +__pycache__ +*$py.classs +.venv + +# datas or personal files +/build +/dist + +# tests +/.tests +/.pytest_cache + +# doc +/apidoc/generated + +# IDEs folders +.vscode @@ -0,0 +1,10 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +## [1.0.0] - 2023-01-09 + +Initial release. @@ -0,0 +1,113 @@ +# Contribution Guidelines + +## Setup Developer Environment +This is a step-by-step guide to get a functional development environment for maintaining Blender Python add-ons, using Visual Studio Code IDE. + +### Requirements +Download and install for your platform: +- [Blender 3.3 LTS](https://blender.org/download) - use local development build or use a portable zip version +- [VSCode](https://code.visualstudio.com/) +- [Git](https://git-scm.com/downloads) + +### Create a Python Virtual Environment +Python virtual environments are useful to create isolated development environments, especially regarding package management. + +- define the folder where you will be storing your virtual environments (here: `/path/to/venvs`) +- create a virtual environment for Blender development using Blender's embedded Python interpreter + - location: `{blender_install_path}/{blender_version}/python/bin/python` + - referred to as BL_PYEXE in the rest of this document + +``` +cd /path/to/venvs +# Make sure pip is available +BL_PYEXE -m ensurepip +# Create a new virtual environment +BL_PYEXE -m venv blender_dev +cd blender_dev +# Windows: +Script\activate +# Linux: +source Script/activate +``` +Now that the virtual environment is activated, install the requirements +``` +pip install -r requirements.dev.txt +``` + +### Setting up VSCode +Open VSCode. + +#### Configure Python +- make sure the Python langage extension is enabled - open any `.py` file to trigger its installation. +- in the settings, setup the path to your virtual envs folder (either in UI or in JSON): +- WARNING: use the path to the folder **containing** `blender_dev` +``` +"python.venvPath": "/path/to/venvs" +``` +- restart VSCode to update the list of available interpreters + +Now, open a project - i.e the folder containing the source of an add-on. +- open a Python file to trigger the Python interpreter selection +- interpreter: click on "Select the Python Interpreter" and set it to `blender_dev` +- linter: Ctrl+Shift+P > Select Linter > flake8 + + +#### Blender Development extension +From the Extensions panel, install `Blender Development` by Jacques Lucke. + +New commands are available in the Command Palette (Ctrl+Shift+P): +- **Blender: Start** + - chose a Blender executable the first time + - requires Admin privileges if Blender is installed in a protected folder + - this automatically installs the addon in development + - Python debugger is attached to the instance of Blender, enabling the use of breakpoints in Python code +- **Blender: Reload Addons** + - reload the addon in development + - for more convenience, you can activate the reloadOnSave option in the extension settings + - `"blender.addon.reloadOnSave": true` + +#### Other Recommanded Extensions +- Version Control + - Git Graph + - GitLens + - Gitlab +- Utilities + - Clipboard Manager + + +## Analysis and Testing + +Running static analysis and unit tests requires a complete Blender Python environment with core packages (bpy, mathutils...) available. +First, install requirements within Blender's own interpreter. +``` +BL_PYEXE -m pip install -r requirements.tests.py +``` + +### Running static analysis +``` +blender -b -P scripts\run_mypy.py +``` + +### Running unit tests + +``` +blender -b -P scripts\run_pytest.py +``` + + +## API Documentation +The API documentation is generated automatically from Python docstrings using sphinx. +Like for analysing and testing, this requires a complete Blender Python environment. + +First, install the requirements in Blender's own interpreter. +``` +BL_PYEXE -m pip install -r requirements.apidoc.txt +``` + +Now, using the Blender executable from this installation, run + +``` +blender -b -P scripts\run_sphinx-build.py +``` + +This generates a static HTML site, within the `apidoc/generated/build` folder by default. @@ -0,0 +1,619 @@ +## GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +<https://fsf.org/> + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom +to share and change all versions of a program--to make sure it remains +free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies +also to any other work released this way by its authors. You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you +have certain responsibilities if you distribute copies of the +software, or if you modify it: responsibilities to respect the freedom +of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the +manufacturer can do so. This is fundamentally incompatible with the +aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for +individuals to use, which is precisely where it is most unacceptable. +Therefore, we have designed this version of the GPL to prohibit the +practice for those products. If such problems arise substantially in +other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the +freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish +to avoid the special danger that patents applied to a free program +could make it effectively proprietary. To prevent this, the GPL +assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or +of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public +License, you may choose any version ever published by the Free +Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU General Public License can be used, that proxy's public +statement of acceptance of a version permanently authorizes you to +choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS @@ -0,0 +1,56 @@ +# SPA Sequencer Addon + +This add-on contains a set of tools that improve the sequence workflow in Blender. +It can be used for Storyboarding or Layout, to work simultaneously on different shots, +leveraging Blender's native concepts of _scenes_ and _cameras_, as well as the _Video Sequence Editor_ (VSE). + +Features: +- Synchronised VSE and scene timelines. +- VSE based shot management system (add, rename, delete, etc.). +- Batch rendering shots from the VSE. +- Editorial import/export capabilities (based on OTIO). +- Shared folders (collection linking between scenes). + +Note: Without the use of the [Blender SPA](https://github.com/The-SPA-Studios/blender) version, the following features will not work properly: + - Timeline syncronization of scenes in the same window. + - Batch rendering using the viewport render. + +The add-on is shared as is, for the community to test, discuss, and hopefully draw inspiration for future Blender versions and tools. +The SPA Studios should not be expected to provide active support for any of the presented features. + +## Install + +To try out the full Blender SPA experience (including this add-on), download the portable build from [this link (Windows 64bits)](https://thespastudios.com/public-links/blender_spa-2.4.1-bundle-windows64.zip), unzip it and run Blender from there. + +To test this add-on separately, download the latest release and install as any Blender add-on (see [documentation](https://docs.blender.org/manual/en/latest/editors/preferences/addons.html#installing-add-ons)). +Please note that some features will not work properly when used with an official Blender build. + +## User Guide + +User documentation is available [here](https://the-spa-studios.github.io/blender-spa-userdoc/). + +## Demo + +The sequencer workflow was presented at the 2022 Blender Conference: +https://youtu.be/0HNmJebYY8M?t=1937 + +## Contribute + +Read more about the developer's setup in [CONTRIBUTING.md](./CONTRIBUTING.md). + +## License + +_SPA Sequencer Addon_ is licensed under the GNU General Public License, Version 3 or later. + +A full copy of the GPLv3 license can be found at [COPYING.md](./COPYING.md). + +### Additional Terms + +As per section 7 of the GPLv3, the license is supplemented with the following additionnal term(s): + +- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +## Authors + +The SPA Studios pipeline team (2021-2023): +Antoine Boellinger ([@aboellinger](https://github.com/aboellinger)), Bryan Fordney ([@bryab](https://github.com/bryab)), Falk David ([@falkdavid](https://github.com/falkdavid)), Mickael Villain ([@micka-06](https://github.com/micka-06)), Nick Alberelli ([@NickTiny](https://github.com/NickTiny)), Paolo Fazio ([@PaoloFazio](https://github.com/PaoloFazio)), Yann Lanthony ([@yann-lty](https://github.com/yann-lty)). @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +""" +Configuration file for the Sphinx documentation builder. + +This file only contains a selection of the most common options. For a full +list see the documentation: +https://www.sphinx-doc.org/en/master/usage/configuration.html +""" + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import sys +from pathlib import Path + +sys.path.insert(0, Path(__file__).parent.parent.as_posix()) + +import spa_sequencer + +# -- Project information ----------------------------------------------------- + +project = "SPA Sequencer Addon API" +copyright = "2023, The SPA Studios" +author = "The SPA Studios" + + +# The full version, including alpha/beta/rc tags +release = ".".join(map(str, spa_sequencer.bl_info["version"])) + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named "sphinx.ext.*") or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinxcontrib.apidoc", + "sphinx.ext.viewcode", + "sphinx_autodoc_typehints", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +html_theme_options = { + "navigation_depth": 4, + "titles_only": True, +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ["_static"] + +add_module_names = False +autodoc_inherit_docstrings = False + + +apidoc_module_dir = "../spa_sequencer" +apidoc_output_dir = "generated/reference" +apidoc_separate_modules = True @@ -0,0 +1,17 @@ + +Welcome to SPA Sequencer Addon's API documentation! +================================================ + +.. toctree:: + :caption: Contents: + :maxdepth: 4 + + generated/reference/modules + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` @@ -0,0 +1,4 @@ +sphinx==5.3.0 +sphinx-rtd-theme==1.1.0 +sphinx-autodoc-typehints=1.19.5 +sphinxcontrib-apidoc==0.3.0 \ No newline at end of file @@ -0,0 +1,3 @@ +fake-bpy-module-3.1 +flake8==3.7.9 +black==19.10b0 \ No newline at end of file @@ -0,0 +1,2 @@ +pytest==7.1.2 +mypy==0.950 \ No newline at end of file @@ -0,0 +1 @@ +OpenTimelineIO==0.15.0.1 \ No newline at end of file @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import sys + +import mypy.api + + +SOURCE_FOLDER = "spa_sequencer" + + +def main(): + """Run static analysis and return mypy's exit code.""" + stdout, stderr, rc = mypy.api.run([SOURCE_FOLDER]) + + if stdout: + print(f"Type checking report:\n{stdout}") + + if stderr: + print(f"Error report:\n{stderr}") + + print(f"Mypy exit status: {rc}") + return rc + + +if __name__ == "__main__": + sys.exit(main()) @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import sys + +import pytest + + +TESTS_FOLDER = "tests" + + +def main(args): + """Run tests and return pytest's exit code.""" + return pytest.main([TESTS_FOLDER] + args) + + +if __name__ == "__main__": + script_args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + sys.exit(main(script_args)) @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import os +import sys + +from sphinx.cmd import build + + +CONFIG_FOLDER = "apidoc" +BUILD_FOLDER = os.path.join(CONFIG_FOLDER, "generated", "build") + + +def main(): + """Run sphinx-build to generate a HTML version of addon's API documentation.""" + + # Build sphinx doc + return build.make_main(["M", "html", CONFIG_FOLDER, BUILD_FOLDER]) + + +if __name__ == "__main__": + sys.exit(main()) @@ -0,0 +1,12 @@ +[flake8] +max-line-length = 88 # adapted to Black formatting +extend-ignore = E203 + + +[mypy] +python_version = 3.10 +namespace_packages = True +disable_error_code = valid-type,var-annotated +show_error_codes = True +pretty = True +ignore_missing_imports = True @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from spa_sequencer import ( + editorial, + keymaps, + preferences, + render, + sequence, + shared_folders, + shot, + sync, +) + + +bl_info = { + "name": "Sequencer", + "author": "The SPA Studios", + "description": "Toolset to improve the sequence workflow in Blender.", + "blender": (3, 3, 0), + "version": (1, 0, 0), + "location": "", + "warning": "", + "category": "SPA", +} + + +packages = ( + sync, + shot, + sequence, + render, + editorial, + shared_folders, + preferences, + keymaps, +) + + +def register(): + for package in packages: + package.register() + + +def unregister(): + for package in packages: + package.unregister() @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from spa_sequencer.editorial import ( + ops, + ui, + vse_io, +) + + +def register(): + ops.register() + vse_io.register() + ui.register() + + +def unregister(): + ops.unregister() + vse_io.unregister() + ui.unregister() @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import re +from typing import NamedTuple + +import bpy + + +class StripsGroup(NamedTuple): + group_id: str + strips: list[bpy.types.Sequence] + + @property + def frame_start(self): + """First frame of that group.""" + return self.strips[0].frame_final_start if self.strips else 0 + + @property + def frame_end(self): + """Last frame of that group.""" + return self.strips[-1].frame_final_end if self.strips else 0 + + @property + def frame_duration(self): + """Total duration of that group.""" + return self.frame_end - self.frame_start + + +def gather_strips_groups_by_regex( + strips: list[bpy.types.Sequence], regex_group_id: str +) -> list[StripsGroup]: + """ + Build groups of consecutive strips with similar group id - defined by + `regex_group_id` - in their name. + + :param strips: The sorted list of strips. + :param regex_group_id: The regex defining how to extract group id from strip name. + Must be a single capture group expression. + """ + regex = re.compile(regex_group_id) + + # List to gather consecutive strips by identical group id. + strips_groups: list[StripsGroup] = [] + + for strip in strips: + group_id = "" + # Extract group identifier from name. + res = regex.match(strip.name) + if res: + group_id = res.group(1) + + # Append strip to previous group if it matches + if strips_groups: + prev_group = strips_groups[-1] + if prev_group.group_id == group_id: + prev_group.strips.append(strip) + continue + + # Otherwise, create a new group from this strip + group = StripsGroup(group_id, [strip]) + strips_groups.append(group) + + return strips_groups @@ -0,0 +1,288 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import re + +import bpy + + +from spa_sequencer.shot.naming import ShotNaming, ShotPrefix +from spa_sequencer.shot.core import slip_shot_content +from spa_sequencer.utils import register_classes, unregister_classes + +from spa_sequencer.editorial.core import gather_strips_groups_by_regex +from spa_sequencer.render.tasks import ( + STRIP_PROP_SOURCE_SCENE, + STRIP_PROP_SOURCE_CAMERA, + STRIP_PROP_SOURCE_FRAME_START, + STRIP_PROP_SOURCE_FRAME_END, +) + +from spa_sequencer.sync.core import remap_frame_value + + +def get_shot_prefix_enum(self, ctx): + return [(item.value, item.value, item.name) for item in ShotPrefix] + + +class SEQUENCER_OT_edit_conform_shots_from_panels(bpy.types.Operator): + bl_idname = "sequencer.edit_conform_shots_from_panels" + bl_label = "Generate Shots From Panels" + bl_description = "Generate a scene strip track based on a media track with panels" + bl_options = {"UNDO", "REGISTER"} + + ref_channel: bpy.props.IntProperty( + name="Reference Channel", + description="Channel that contains the panels", + default=1, + ) + + shot_id_regex: bpy.props.StringProperty( + name="Shot ID Regex", + description="Single capture group regex to extract shot id from panel names", + default=r"\w*_s(\d*)_?\w*", + ) + + shot_prefix: bpy.props.EnumProperty( + name="Shot Prefix", + description="Shot prefix", + items=get_shot_prefix_enum, + ) + + def get_scenes(self, context): + """ + Get all scenes that can be used by scene strips, i.e: + - are not the current one + - have a camera + """ + # Store this on the class to keep a valid Python reference to string props. + scenes = [ + (s.name, s.name, "") + for s in bpy.data.scenes + if s != context.scene and s.camera + ] + if not scenes: + scenes = [("NONE", "No Valid Shot Scene", "No valid shot scene found")] + SEQUENCER_OT_edit_conform_shots_from_panels._scenes = scenes + return SEQUENCER_OT_edit_conform_shots_from_panels._scenes + + shot_scene: bpy.props.EnumProperty( + name="Shot Scene", + description="The scene the generated shot scene strips should use", + items=get_scenes, + ) + + target_channel: bpy.props.IntProperty( + name="Target Channel", + description="Channel to create the shot scene strips on", + default=2, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return context.scene.sequence_editor is not None + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context): + if self.shot_scene == "NONE": + self.report({"ERROR"}, "No valid shot Scene") + return {"CANCELLED"} + + seq_editor = context.scene.sequence_editor + + # Get list of strips from reference channel. + ref_strips = sorted( + [s for s in seq_editor.sequences if s.channel == self.ref_channel], + key=lambda x: x.frame_final_start, + ) + + # Build strip groups based on shot regex. + strips_groups = gather_strips_groups_by_regex(ref_strips, self.shot_id_regex) + + shot_naming = ShotNaming() + + # Build shot scene strips based on reference strip groups. + shot_scene = bpy.data.scenes[self.shot_scene] + for number, group in enumerate(strips_groups): + shot_name = shot_naming.build_shot_name((number + 1) * 10, self.shot_prefix) + + # Create a new scene strip at frame start of the first ref strip in this group. + shot_strip = seq_editor.sequences.new_scene( + shot_name, + shot_scene, + self.target_channel, + group.frame_start, + ) + # Match the duration of the whole group. + shot_strip.frame_final_duration = group.frame_duration + # Adjust internal offset to target the correct range within the strip's scene. + slip_shot_content(shot_strip, shot_strip.frame_final_start) + # Assign active camera of the scene. + shot_strip.scene_camera = shot_scene.camera + + # Trigger a sequencer update. + bpy.ops.sequencer.reload() + + self.report( + {"INFO"}, + f"Created {len(strips_groups)} shots from {len(ref_strips)} panels", + ) + return {"FINISHED"} + + +class SEQUENCER_OT_edit_conform_shots_from_editorial(bpy.types.Operator): + bl_idname = "sequencer.edit_conform_shots_from_editorial" + bl_label = "Conform Shots From Editorial" + bl_description = ( + "Generate a scene strip track based on a media track with movie strips" + ) + bl_options = {"UNDO"} + + ref_channel: bpy.props.IntProperty( + name="Reference Channel", + description="Channel that contains the movie strips (0 for all)", + default=0, + ) + + target_channel: bpy.props.IntProperty( + name="Target Channel", + description="Channel to create the edit on", + default=2, + ) + + shot_prefix: bpy.props.EnumProperty( + name="Shot Prefix", + description="Shot prefix", + items=get_shot_prefix_enum, + ) + + shot_id_regex: bpy.props.StringProperty( + name="Shot ID Regex", + description="Single capture group regex to extract shot id from panel names", + default=r"\w*_PSH(\d*)\.\w*", + ) + + freeze_frame_handles: bpy.props.IntVectorProperty( + name="Freeze Frame Handles", + description="Freeze frame handles (left/right)", + size=2, + min=0, + soft_max=240, + default=[100, 100], + ) + + freeze_frame_handles_applied: bpy.props.BoolProperty( + name="Freeze Frames Handles Applied", + description=( + "Whether freeze frame handlers are applied to metadata values" + " or need to be taken into account" + ), + default=True, + ) + + freeze_frame_handles_warning: bpy.props.BoolProperty( + name="Freeze Frame Handles Warning", + description="Color strips that exceed original source range (using freeze frame handles)", + default=True, + ) + + def draw(self, context: bpy.types.Context): + self.layout.use_property_split = True + self.layout.prop(self, "ref_channel") + self.layout.prop(self, "target_channel") + self.layout.prop(self, "shot_prefix") + self.layout.prop(self, "shot_id_regex") + box = self.layout.box() + box.prop(self, "freeze_frame_handles", expand=True) + box.prop(self, "freeze_frame_handles_applied", text="Applied to Metadata") + box.prop(self, "freeze_frame_handles_warning", text="Warn Usage") + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: bpy.types.Context): + + seq_editor = context.scene.sequence_editor + regex = re.compile(self.shot_id_regex) + + shot_naming = ShotNaming() + + for strip in seq_editor.sequences: + + if self.ref_channel != 0 and strip.channel != self.ref_channel: + continue + + # We expect to find specific keys on movie strips to reconstruct scene strips + scene = bpy.data.scenes.get(strip.get(STRIP_PROP_SOURCE_SCENE, "")) + + if not scene: + continue + + # Extract additional metadata from strip + camera = bpy.data.objects.get(strip.get(STRIP_PROP_SOURCE_CAMERA, "")) + frame_start = int(strip.get(STRIP_PROP_SOURCE_FRAME_START, 0)) + frame_end = int( + strip.get( + STRIP_PROP_SOURCE_FRAME_END, + (frame_start + strip.frame_duration - 1), + ) + ) + + # Compute source frame start without freeze frame handles + source_frame_start = frame_start + source_frame_end = frame_end + + if not self.freeze_frame_handles_applied: + # Extend strip's internal range by handles. + frame_start -= self.freeze_frame_handles[0] + frame_end += self.freeze_frame_handles[1] + else: + # Compute original strip range by removing handles. + source_frame_start += self.freeze_frame_handles[0] + source_frame_end -= self.freeze_frame_handles[1] + + # Try to extract shot name from strip name. + res = regex.search(strip.name) + if res: + shot_number = int(res.group(1)) + shot_name = shot_naming.build_shot_name(shot_number) + else: + shot_name = shot_naming.next_shot_name_from_sequences(seq_editor) + + # Create a new scene strip using extracted information. + shot_strip = seq_editor.sequences.new_scene( + shot_name, scene, self.target_channel, strip.frame_final_start + ) + shot_strip.scene_camera = camera + # Adjust timing. + shot_strip.frame_final_duration = strip.frame_final_duration + shot_strip.frame_final_start = strip.frame_final_start + start_offset = frame_start + strip.frame_offset_start - 1 + slip_shot_content(shot_strip, start_offset) + + # Detect if shot is exceeding initial rendering range. + if self.freeze_frame_handles_warning: + frame_start = remap_frame_value( + shot_strip.frame_final_start, shot_strip + ) + frame_end = remap_frame_value(shot_strip.frame_final_end, shot_strip) + if frame_start < source_frame_start or frame_end > source_frame_end: + shot_strip.color_tag = "COLOR_02" + + return {"FINISHED"} + + +classes = ( + SEQUENCER_OT_edit_conform_shots_from_panels, + SEQUENCER_OT_edit_conform_shots_from_editorial, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes +from spa_sequencer.editorial.vse_io import HAS_OTIO + + +class SEQUENCER_MT_edit_conform(bpy.types.Menu): + bl_idname = "SEQUENCER_MT_edit_conform" + bl_label = "Conform" + + def draw(self, context): + self.layout.operator("sequencer.edit_conform_shots_from_panels") + self.layout.operator("sequencer.edit_conform_shots_from_editorial") + + +class SEQUENCER_MT_editorial(bpy.types.Menu): + """Editorial operators menu""" + + bl_idname = "SEQUENCER_MT_editorial" + bl_label = "Editorial" + + def draw(self, context): + + if HAS_OTIO: + self.layout.menu("SEQUENCER_MT_edit_io") + + self.layout.menu("SEQUENCER_MT_edit_conform") + + +def draw_MT_editorial(self, context): + self.layout.menu(SEQUENCER_MT_editorial.bl_idname) + + +classes = ( + SEQUENCER_MT_edit_conform, + SEQUENCER_MT_editorial, +) + + +def register(): + register_classes(classes) + bpy.types.SEQUENCER_MT_editor_menus.append(draw_MT_editorial) + + +def unregister(): + unregister_classes(classes) + bpy.types.SEQUENCER_MT_editor_menus.remove(draw_MT_editorial) @@ -0,0 +1,26 @@ +import logging + +log = logging.getLogger(__name__) + +# Safe import modules depending on OpenTimelineIO. +try: + from spa_sequencer.editorial.vse_io import ops, ui + + HAS_OTIO = True +except ModuleNotFoundError: + HAS_OTIO = False + + +def register(): + # Only register modules if OTIO is available. + if HAS_OTIO: + ops.register() + ui.register() + else: + log.warn("VSE-IO features unavailable: 'opentimelineio' is missing") + + +def unregister(): + if HAS_OTIO: + ops.unregister() + ui.unregister() @@ -0,0 +1,114 @@ +import os +from pathlib import Path +from typing import Callable +from urllib.parse import unquote, urlparse +from urllib.request import url2pathname + +import opentimelineio as otio + +import bpy + + +@staticmethod +def url2filepath(url: str): + """Convert a URL of the 'file' scheme to a file system path.""" + if not url.startswith("file://"): + return url + + if url.startswith("file:///"): + parsed = urlparse(url) + filepath = url2pathname(unquote(parsed.path)) + else: + # Non standard URI with only two beginning slashes + filepath = unquote(url).replace("file://", "") + + return filepath + + +def get_media_reference_filepath( + media_reference: otio.schema.ExternalReference, parent_path: str +) -> str: + """ + Get an absolute filepath from a given `media_reference`, using `parent_path` as + root path if the internal media reference is relative. + + :param media_reference: The OTIO external media reference to consider. + :param parent_dir: The parent path to build an absolute path if needed. + :return: The absolute filepath. + """ + if os.path.isfile(media_reference.target_url): + return media_reference.target_url + + filepath = url2filepath(media_reference.target_url) + + # Remap relative filepath to absolute path from given parent path. + if not os.path.isabs(filepath): + filepath = (Path(parent_path) / filepath).as_posix() + + return filepath + + +def sequencer_add_media_func( + seq_editor: bpy.types.SequenceEditor, track: otio.schema.Track, media_filepath: str +) -> Callable: + """ + Get sequence editor's function to add a new media strip based on `track`'s type + and `media_filepath`'s extension. + + :param seq_editor: The sequence editor. + :param track: The OTIO source track. + :param media_filepath: The media filepath, used to consider the extension. + :return: The matching function to add the media to the sequence editor. + """ + match track.kind: + case otio.schema.TrackKind.Video: + if Path(media_filepath).suffix in bpy.path.extensions_image: + return seq_editor.sequences.new_image + else: + return seq_editor.sequences.new_movie + case otio.schema.TrackKind.Audio: + return seq_editor.sequences.new_sound + case _: + raise ValueError("Invalid track kind") + + +def strip_apply_frame_offsets( + strip: bpy.types.Sequence, + frame_final_start: int, + frame_final_duration: int, + frame_start_offset: int, +): + """ + Apply strip external and internal offsets in the correct order to get + final frame start, final frame duration and internal frame start to match the + given values, without changing the strip's channel. + + :param strip: The strip to apply offsets to. + :param frame_final_start: The target final frame start. + :param frame_final_duration: The target final frame duration. + :param frame_start_offset: The target frame start offset. + """ + + # This requires this sequence of actions in this order + # for the clip to be set with the correct timings, + # on a channel with enough empty space. + + # 1. Restore "external" start/end properties. + # NOTE: adjust frame end first to be compliant with Blender internal checks + # to avoid start > end. + final_frame_end = frame_final_start + frame_final_duration + strip.frame_final_end = final_frame_end + strip.frame_final_start = frame_final_start + + # Blender might have moved the strip to another + # channel to avoid overwrite - store this. + auto_channel = strip.channel + # 2. Apply strip "internal" offset. + strip.frame_start -= frame_start_offset + # 3. Restore "external" properties again. + strip.frame_final_end = final_frame_end + strip.frame_final_start = frame_final_start + # 4. Counter any change of channel from 3. + # by resetting the auto channel from 1. + if strip.channel != auto_channel: + strip.channel = auto_channel @@ -0,0 +1,468 @@ +import json +import os +from pathlib import Path + + +import bpy +from bpy_extras.io_utils import ExportHelper, ImportHelper + +import opentimelineio as otio +from opentimelineio.opentime import TimeRange, RationalTime + +from spa_sequencer.utils import register_classes, unregister_classes + +from spa_sequencer.editorial.vse_io.core import ( + get_media_reference_filepath, + sequencer_add_media_func, + strip_apply_frame_offsets, +) + + +# Name of custom property to store source AAF metadata on a strip. +STRIP_PROP_AAF_METADATA = "aaf_metadata" + +# Name of custom property to store the path to a missing media reference after import. +STRIP_PROP_MISSING_REFERENCE_PATH = "missing_reference" + +# Strip color to use for missing media references when importing timelines. +STRIP_MISSING_REFERENCE_COLOR = 0.7, 0.0, 0.7 + +# Prefix to add to AAF metadata written to source clips on export. +EXPORT_AAF_BLENDER_METADATA_PREFIX = "Blender." + + +class IMPORT_OT_otio(bpy.types.Operator, ImportHelper): + bl_idname = "import.vse_otio" + bl_label = "Import Timeline" + bl_description = "Import a timeline using OTIO" + bl_options = {"UNDO", "REGISTER", "PRESET"} + + filter_glob: bpy.props.StringProperty( + default="*.otio;*.xml;*.aaf;*.edl;", options={"HIDDEN"} + ) + + def transcribe_otio_track( + self, track: otio.schema.Track, frame_start: int, channel: int + ): + """ + Transcribe an OTIO `track` to Blender, by creating VSE strips from + OTIO clips on specified `channel`. + + :param track: The track to transcribe. + :param frame_start: Frame start of the track. + :param channel: VSE channel index. + """ + + insert_frame = frame_start + + for clip in track.each_child(): + # Only consider source clips and gaps for now, skip everything else. + if not isinstance(clip, (otio.schema.Clip, otio.schema.Gap)): + continue + # Gaps: only move insertion frame by its duration. + if isinstance(clip, otio.schema.Gap): + insert_frame += otio.opentime.to_frames(clip.source_range.duration) + else: + insert_frame += self.transcribe_otio_source_clip( + clip, insert_frame, channel + ) + + def add_missing_reference_clip( + self, name: str, frame_start: int, duration: int, channel: int + ) -> bpy.types.Sequence: + """Add a strip representing a missing media clip. + + :param seq_editor: Sequence editor to add the strip to. + :param name: Strip name. + :param frame_start: Strip frame start. + :param duration: Strip duration. + :param channel: Strip channel. + """ + new_strip = self.seq_editor.sequences.new_effect( + name=name, + type="COLOR", + channel=channel, + frame_start=frame_start, + frame_end=frame_start + duration, + ) + # Indicate missing media reference. + new_strip.color = STRIP_MISSING_REFERENCE_COLOR + return new_strip + + def add_valid_reference_clip( + self, + clip: otio.schema.Clip, + filepath: str, + channel: int, + insert_frame: int, + duration: int, + ) -> bpy.types.Sequence: + """Create a media strip from given `filepath`, based on the given OTIO `clip`. + + :param clip: The reference OTIO clip. + :param filepath: The solved media reference filepath. + :param channel: The channel to add the strip to. + :param insert_frame: The frame the strip to create the strip at. + :param duration: The final duration of the strip. + :return: The newly created media strip. + """ + # Create a new media strip to an external reference. + add_media_strip = sequencer_add_media_func( + self.seq_editor, clip.parent(), filepath + ) + strip = add_media_strip( + name=clip.name, + filepath=filepath, + channel=channel, + frame_start=insert_frame, + ) + offset_start = otio.opentime.to_frames(clip.source_range.start_time) + strip_apply_frame_offsets(strip, insert_frame, duration, offset_start) + return strip + + def transcribe_otio_source_clip( + self, + clip: otio.schema.Clip, + insert_frame: int, + channel: int, + ) -> int: + """Transcribe an OTIO `clip` into a media strip. + + If the strip does not point to a valid reference on disk, the created strip + will be a ColorSequence with a custom property set to the missing media + filepath (see `STRIP_PROP_MISSING_REFERENCE_PATH`). + + :param clip: The source OTIO clip. + :param insert_frame: The frame to create the media strip at. + :param channel: The target sequence editor channel. + :return: The newly created strip. + """ + + if isinstance(clip.media_reference, otio.schema.ExternalReference): + filepath = get_media_reference_filepath( + clip.media_reference, os.path.dirname(self.filepath) + ) + else: + filepath = "" + + duration = otio.opentime.to_frames(clip.source_range.duration) + has_valid_external_reference = os.path.isfile(filepath) + new_strip = None + + if has_valid_external_reference: + new_strip = self.add_valid_reference_clip( + clip, filepath, channel, insert_frame, duration + ) + + else: + # Add a missing media strip + new_strip = self.add_missing_reference_clip( + clip.name, insert_frame, duration, channel + ) + if filepath: + # Store missing reference filepath that can + # be used for a-posteriori relinking. + new_strip[STRIP_PROP_MISSING_REFERENCE_PATH] = filepath + + # Store comments as a custom property on strip. + metadata = clip.media_reference.metadata + if user_comments := metadata.get("AAF", {}).get("UserComments"): + new_strip[STRIP_PROP_AAF_METADATA] = json.dumps(dict(user_comments)) + # Restore Blender related metadata as strip properties + for key, value in user_comments.items(): + if key.startswith(EXPORT_AAF_BLENDER_METADATA_PREFIX): + new_strip[ + key.replace(EXPORT_AAF_BLENDER_METADATA_PREFIX, "") + ] = value + + return duration + + def execute(self, context: bpy.types.Context): + scene = context.scene + if not scene.sequence_editor: + scene.sequence_editor_create() + + self.seq_editor = scene.sequence_editor + + timeline = otio.adapters.read_from_file(self.filepath) + tracks = timeline.audio_tracks() + timeline.video_tracks() + + for idx, track in enumerate(tracks): + channel = idx + 1 + self.transcribe_otio_track(track, scene.frame_start, channel) + + bpy.ops.sequencer.refresh_all() + return {"FINISHED"} + + +class EXPORT_OT_otio(bpy.types.Operator, ExportHelper): + bl_idname = "export.vse_otio" + bl_label = "Export Timeline" + bl_description = "Export a VSE timeline using OTIO" + bl_options = {"PRESET"} + + filename_ext = "" + + file_format: bpy.props.EnumProperty( + items=( + ("aaf", "AAF", "Avid Authoring Format"), + ("edl", "EDL", "EDL CXMP"), + ("otio", "OTIO", "OTIO"), + ) + ) + + filter_glob: bpy.props.StringProperty( + default="*.aaf;*.edl;*.otio", options={"HIDDEN"} + ) + + relative_paths: bpy.props.BoolProperty( + name="Relative Paths", + description="Use relative paths for media URLs instead of absolute paths", + default=False, + ) + + write_metadata: bpy.props.BoolProperty( + name="Metadata", + description="Write metadata to AAF files", + default=True, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return context.scene.sequence_editor is not None + + def execute(self, context: bpy.types.Context): + seq_editor: bpy.types.SequenceEditor = context.scene.sequence_editor + + try: + timeline = self.build_otio_timeline(seq_editor) + except RuntimeError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + # Write output file. + export_filepath = bpy.path.ensure_ext(self.filepath, f".{self.file_format}") + params = dict() + if self.file_format == "aaf": + params["use_empty_mob_ids"] = True + otio.adapters.write_to_file(timeline, export_filepath, **params) + + self.report({"INFO"}, "Export done!") + return {"FINISHED"} + + def build_otio_timeline( + self, seq_editor: bpy.types.SequenceEditor + ) -> otio.schema.Timeline: + """Build an OTIO timeline from a Blender sequence editor. + + :param seq_editor: The sequence editor. + :return: The created timeline. + """ + + scene: bpy.types.Scene = seq_editor.id_data + fps = scene.render.fps + + # Create an OTIO timeline + timeline = otio.schema.Timeline(name=scene.name) + timeline.global_start_time = RationalTime(scene.frame_start, fps) + + # Build a map of strips per channels. + tracks = {} + for strip in seq_editor.sequences: + if strip.channel not in tracks: + tracks[strip.channel] = [] + tracks[strip.channel].append(strip) + + # Transcribe VSE channels to OTIO tracks. + for track_id, strips in tracks.items(): + self.add_track( + timeline, + seq_editor.channels[track_id].name, + scene.frame_start, + strips, + fps, + ) + + return timeline + + @staticmethod + def track_kind_from_strip(strip: bpy.types.Sequence) -> otio.schema.TrackKind: + """Return otio track kind based on strip type.""" + match type(strip): + case bpy.types.SoundSequence: + return otio.schema.TrackKind.Audio + case _: + return otio.schema.TrackKind.Video + + def add_track( + self, + timeline: otio.schema.Timeline, + track_name: str, + frame_start: int, + strips: list[bpy.types.Sequence], + timeline_fps: int, + ) -> otio.schema.Track: + """Add a new track to `timeline` from input VSE `strips`. + + :param timeline: OTIO timeline. + :param track_name: New track name. + :param frame_start: New track frame start. + :param strips: The list of strips. + :param timeline_fps: Timeline framerate. + :return: The created track. + """ + + # Create a new otio track and use first strip's type to assign a track kind. + track = otio.schema.Track(name=track_name) + + # Ensure all strips are of the same kind. + kinds = set(self.track_kind_from_strip(s) for s in strips) + if not len(kinds) == 1: + raise ValueError(f"Different strip types on track '{track_name}'") + track.kind = kinds.pop() + + # Sort strips by frame start. + sorted_strip = sorted(strips, key=lambda x: x.frame_final_start) + # Initialize insert time using first strip start frame. + # Getting first element is safe, sequences can not be empty here. + insert_time = frame_start + + # Iterate over strips. + for strip in sorted_strip: + # Add a gap if the clip is not starting right at current insertion time. + if insert_time < strip.frame_final_start: + gap_duration = strip.frame_final_start - insert_time + self.add_gap(track, insert_time, gap_duration, timeline_fps) + # Add a clip to the track to represent current strip. + self.add_clip(track, strip, timeline_fps) + insert_time = strip.frame_final_end + + timeline.tracks.append(track) + return track + + @staticmethod + def add_gap( + track: otio.schema.Track, frame_start: int, duration: int, fps: int + ) -> otio.schema.Gap: + """Add a gap of the specified `duration` to `track`, at `frame_start`. + + :param track: The track to add the clip to. + :param frame_start: Gap start frame. + :param duration: Gap duration + :param timeline_fps: Timeline framerate. + :return: The created gap. + """ + gap = otio.schema.Gap() + gap.source_range = TimeRange( + start_time=RationalTime(frame_start, fps), + duration=RationalTime(duration, fps), + ) + track.append(gap) + return gap + + def add_clip( + self, track: otio.schema.Track, strip: bpy.types.Sequence, timeline_fps: int + ) -> otio.schema.Clip: + """Add a new clip on `track` based on input `strip`. + + :param track: The track to add the clip to. + :param strip: The input sequence strip. + :param timeline_fps: Timeline framerate. + :return: The newly created clip. + """ + media_fps = timeline_fps + + # Retrieve filepath based on strip type. + match type(strip): + case bpy.types.SoundSequence: + media_filepath = strip.sound.filepath + case bpy.types.ImageSequence: + media_filepath = os.path.join( + strip.directory, strip.elements[0].filename + ) + case bpy.types.MovieSequence: + media_filepath = strip.filepath + media_fps = strip.fps or timeline_fps + case _: + media_filepath = "" + + # Conform filepath. + if media_filepath: + if self.relative_paths: + # This seems to work best with Avid: simple relative path + media_filepath = ( + Path(bpy.path.relpath(media_filepath)).as_posix().strip("//") + ) + else: + # This works best with Resolve, full absolute path + abspath = bpy.path.abspath(media_filepath) + media_filepath = Path(abspath).as_posix() + + # Compute strip internal offset. + # TODO: Test for the sign of this offset when importing in other software. + offset = strip.frame_final_start - strip.frame_start + clip = otio.schema.Clip( + name=strip.name, + source_range=otio.opentime.TimeRange( + start_time=RationalTime(offset, timeline_fps), + duration=RationalTime(strip.frame_final_duration, timeline_fps), + ), + ) + + clip.media_reference = otio.schema.ExternalReference( + media_filepath, + available_range=TimeRange( + start_time=RationalTime(0, media_fps), + duration=RationalTime(strip.frame_duration, media_fps), + ), + ) + + if self.write_metadata: + self.write_aaf_clip_metadata(clip, strip) + + track.append(clip) + return clip + + @staticmethod + def write_aaf_clip_metadata(clip: otio.schema.Clip, strip: bpy.types.Sequence): + """ + Write custom AAF metadata to `clip` to identify its source. + This includes: + - the media filepath as UNC Path + - source scene strip information if `strip` is the output of a render + + :param clip: The clip to write metadata to. + :param strip: The source sequence strip. + """ + # Only handle clips with external references. + if not isinstance(clip.media_reference, otio.schema.ExternalReference): + return + + # Fill UNC path property with media's filepath, as it is considered as + # part of media location solving when importing an AAF via OTIO. + metadata = {"UNC Path": clip.media_reference.target_url} + + # Consider strip's custom properties (accessed via `items`) as metadata. + + metadata.update( + { + f"{EXPORT_AAF_BLENDER_METADATA_PREFIX}{key}": str(value) + for key, value in strip.items() + } + ) + + clip.media_reference.metadata.update({"AAF": {"UserComments": metadata}}) + + +classes = ( + EXPORT_OT_otio, + IMPORT_OT_otio, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,23 @@ +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes + + +class SEQUENCER_MT_edit_io(bpy.types.Menu): + bl_idname = "SEQUENCER_MT_edit_io" + bl_label = "Timeline I/O" + + def draw(self, context): + self.layout.operator("import.vse_otio", text="Import Timeline...") + self.layout.operator("export.vse_otio", text="Export Timeline...") + + +classes = (SEQUENCER_MT_edit_io,) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bgl +import bpy +import gpu + +from gpu_extras.batch import batch_for_shader + + +Vec3 = tuple[int, int, int] +Vec2f = tuple[float, float] +Vec4f = tuple[float, float, float, float] + + +def ui_scaled(val): + """Return value multiplied by UI scale factor.""" + return val * bpy.context.preferences.system.ui_scale + + +class OverlayDrawer: + """Helper class to draw overlays using bgl and gpu API.""" + + def __init__(self): + self.format = gpu.types.GPUVertFormat() + self.shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR") + + self.pos_id = self.format.attr_add( + id="pos", comp_type="F32", len=2, fetch_mode="FLOAT" + ) + self.color_id = self.format.attr_add( + id="color", comp_type="F32", len=2, fetch_mode="FLOAT" + ) + + def draw(self, coords: list[Vec2f], indices: list[Vec3], color: Vec4f): + bgl.glEnable(bgl.GL_BLEND) + + batch = batch_for_shader(self.shader, "TRIS", {"pos": coords}, indices=indices) + batch.program_set(self.shader) + self.shader.uniform_float("color", color) + batch.draw() + + bgl.glDisable(bgl.GL_BLEND) + + def draw_points(self, coords: list[Vec2f], color: Vec4f): + """Draw points at specified coords. + + :param coords: Points coordinates. + :param color: Color of the points. + """ + bgl.glEnable(bgl.GL_BLEND) + batch = batch_for_shader(self.shader, "POINTS", {"pos": coords}) + batch.program_set(self.shader) + self.shader.uniform_float("color", color) + batch.draw() + bgl.glDisable(bgl.GL_BLEND) + + def draw_lines(self, coords: list[Vec2f], color: Vec4f): + """Draw lines at specified coords. + + :param coords: Lines points coordinates. + :param color: Color of the lines. + """ + bgl.glEnable(bgl.GL_BLEND) + batch = batch_for_shader(self.shader, "LINES", {"pos": coords}) + batch.program_set(self.shader) + self.shader.uniform_float("color", color) + batch.draw() + bgl.glDisable(bgl.GL_BLEND) + + def draw_rect(self, x: float, y: float, width: float, height: float, color: Vec4f): + """Draw a filled rectangle, with [x,y] origin at bottom left corner. + + :param x: x coordinate. + :param y: y coordinate. + :param width: Width of the rectangle. + :param height: Height of the rectangle. + :param color: Color of the rectangle. + """ + coords = [(x, y), (x + width, y), (x + width, y + height), (x, y + height)] + # Triangles indices + indices = [(0, 1, 2), (2, 0, 3)] + self.draw(coords, indices, color) + + def draw_box(self, x: float, y: float, width: float, height: float, color: Vec4f): + """Draw an outline box, with [x,y] origin at bottom left corner. + + :param x: x coordinate. + :param y: y coordinate. + :param width: Width of the box. + :param height: Height of the box. + :param color: Color of the outmone. + """ + bl = (x, y) + br = (x + width, y) + tr = (x + width, y + height) + tl = (x, y + height) + coords = [bl, br, br, tr, tr, tl, tl, bl] + self.draw_lines(coords, color) @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +""" +Addon's keymap registration. +""" + +from typing import Any + +import bpy + +addon_keymaps = [] + + +def register_keymap( + operator: str, + key: str, + shift: bool = False, + ctrl: bool = False, + alt: bool = False, + space_type: str = "VIEW_3D", + category_name: str = "3D View Generic", + value: str = "PRESS", + properties: dict[str, Any] = {}, +): + """ + Register a new keymap in the preferences. + + :param operator: the operator to call + :param key: the shortcut's key name + :param shift: enable shift modifier + :param ctrl: enable ctrl modifier + :param alt: enable alt modifier + :param space_type: the Blender space type where this shortcut applies + :param category_name: the category name (used in Blender preferences) + :param properties: operator's properties + """ + wm = bpy.context.window_manager + + # Keymaps fail to register when using Blender in background mode + if bpy.app.background or not wm: + return + + # Register a new keymap + km = wm.keyconfigs.addon.keymaps.new(name=category_name, space_type=space_type) + # Register a new keymap item + kmi = km.keymap_items.new(operator, key, value, shift=shift, ctrl=ctrl, alt=alt) + # Set the keymap active + kmi.active = True + addon_keymaps.append((km, kmi)) + # Set operator properties + for name, value in properties.items(): + setattr(kmi.properties, name, value) + + return kmi + + +def register(): + pass + + +def unregister(): + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +""" +Addon preferences management. +""" + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes + + +class SPASequencerAddonPreferences(bpy.types.AddonPreferences): + bl_idname = __package__ + + shot_template_prefix: bpy.props.StringProperty( + name="Shot Template Prefix", + description="Scene name prefix that identifies Shot Templates", + default="TEMPLATE_SHOT", + ) + + def draw(self, context): + self.layout.prop(self, "shot_template_prefix") + + +def get_addon_prefs() -> SPASequencerAddonPreferences: + """Get the Addon Preferences instance.""" + return bpy.context.preferences.addons[__package__].preferences + + +classes = (SPASequencerAddonPreferences,) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from spa_sequencer.render import ( + props, + ops, + ui, +) + + +def register(): + props.register() + ops.register() + ui.register() + + +def unregister(): + props.unregister() + ops.unregister() + ui.unregister() @@ -0,0 +1,463 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import functools +from typing import Optional +import traceback + +import bpy + +from spa_sequencer.render.tasks import ( + BaseRenderTask, + BaseTask, + CopySoundStripsTask, + FitResolutionToContentTask, + SequenceRenderTask, + StripRenderTask, + TaskStatus, + ValueOverrides, +) + +from spa_sequencer.sync.core import get_sync_settings +from spa_sequencer.utils import register_classes, unregister_classes + + +class RenderCancelled(RuntimeError): + """Exception indicating a render task was cancelled by the user""" + + pass + + +def close_window(window: bpy.types.Window): + """Helper function to close the window `win`.""" + bpy.ops.wm.window_close({"window": window}, "INVOKE_DEFAULT") + + +class SEQUENCER_OT_batch_render(bpy.types.Operator): + bl_idname = "sequencer.batch_render" + bl_label = "Sequencer Batch Render" + bl_description = "Batch render several scene strips from a sequencer" + bl_options = {"BLOCKING"} + + RENDER_WINDOW_WIDTH = 1080 + + def __init__(self): + self.tasks: list[BaseTask] = [] + self.active_task: Optional[BaseTask] = None + + self.output_channel_offset: int = 0 + self.output_sound_strips: list[bpy.types.SoundSequence] = [] + + self.cancelled: bool = False + + # The temporary render view window + self.render_window: Optional[bpy.types.Window] = None + # An event timer used to trigger automatic updates when rendering + self.render_event_timer: Optional[bpy.types.Timer] = None + + # Global overrides made for rendering + self.global_overrides: ValueOverrides = ValueOverrides() + # Area used for viewport rendering + self.render_viewport_area: Optional[bpy.types.Area] = None + # Window containing the area used for viewport rendering + self.render_viewport_window: Optional[bpy.types.Window] = None + # Overrides made to the render viewport space + self.space_overrides: ValueOverrides = ValueOverrides() + + @classmethod + def poll(cls, context: bpy.types.Context): + return ( + context.scene.sequence_editor + and context.window_manager.batch_render.status != "RUNNING" + ) + + def setup_tasks(self, scene: bpy.types.Scene): + """ + Setup tasks and internals to batch render scene sequences in `scene` based on + its options. + + :param scene: The Scene to setup for. + """ + self.scene = scene + self.render_options = self.scene.batch_render_options + render_op_invoke = self.options.is_invoke + + # Select scene sequence strips to render + seqs = [ + seq + for seq in self.scene.sequence_editor.sequences + if isinstance(seq, bpy.types.SceneSequence) + and (seq.select or not self.render_options.selection_only) + ] + + # Create render tasks + for seq in sorted(seqs, key=lambda x: x.frame_final_start): + self.tasks.append(StripRenderTask(strip=seq, is_modal=render_op_invoke)) + + # Early return if output scene is not set. + if not (output_scene := self.render_options.output_scene): + return + + # List sound strips if they need to be copied over output scene. + if self.render_options.output_copy_sound_strips: + sound_strips = [ + seq + for seq in self.scene.sequence_editor.sequences + if isinstance(seq, bpy.types.SoundSequence) + and (seq.select or not self.render_options.selection_only) + ] + self.output_sound_strips = sorted( + sound_strips, key=lambda x: x.frame_final_start + ) + if self.output_sound_strips: + self.tasks.append( + CopySoundStripsTask( + src_scene=self.scene, + dst_scene=output_scene, + sound_strips=self.output_sound_strips, + ) + ) + + # Output scene setup. + if sed := output_scene.sequence_editor: + # Deselect all strips in output scene's sequencer in order to keep + # only the new strips as selected. + bpy.ops.sequencer.select_all({"scene": output_scene}, action="DESELECT") + + # Compute channel offset in output scene based on existing content + if self.render_options.output_auto_offset_channels and sed.sequences: + self.output_channel_offset = max([s.channel for s in sed.sequences]) + else: + # Ensure sequence editor is created in output scene. + output_scene.sequence_editor_create() + + if self.tasks: + if self.render_options.output_scene: + self.tasks.append(FitResolutionToContentTask(scene=output_scene)) + if self.render_options.render_output_scene: + self.tasks.append( + SequenceRenderTask(scene=output_scene, is_modal=render_op_invoke) + ) + + def render_view_update(self): + """Ensure render view displays the entire image.""" + # Render window only has one Image Editor area. + area = self.render_window.screen.areas[0] + # Get window region. + region = next(region for region in area.regions if region.type == "WINDOW") + # Ensure the entire rendered image is visible in the render window. + bpy.ops.image.view_all( + { + "window": self.render_window, + "area": self.render_window.screen.areas[0], + "region": region, + } + ) + + def cancel(self, context): + """Called when the operator is cancelled, e.g when the user closes the + render window manually.""" + self.cleanup() + self.render_props.status = "CANCELLED" + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + if not self.setup(context): + return {"CANCELLED"} + + self.render_props.status = "RUNNING" + # Start the modal loop. + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + + def setup(self, context): + self.setup_tasks(context.scene) + self.render_props = context.window_manager.batch_render + + if not self.tasks: + self.report({"WARNING"}, f"Nothing to render in {context.scene.name}") + self.render_props.status = "CANCELLED" + return False + + # Disable synchronization while rendering to avoid issues with + # conflicting frame change callbacks behaviors. + self.global_overrides.set(get_sync_settings(), "enabled", False) + + if any( + isinstance(task, (SequenceRenderTask, StripRenderTask)) + for task in self.tasks + ): + # Setting up render window can be skipped in non-modal mode. + if self.options.is_invoke: + self.setup_render_window(context) + + # Update render tasks. + for task in (t for t in self.tasks if isinstance(t, StripRenderTask)): + task.viewport_area = self.render_viewport_area + task.viewport_window = self.render_viewport_window + task.output_channel_offset = self.output_channel_offset + + self.render_props.task_count = len(self.tasks) + return True + + def setup_render_window(self, context: bpy.types.Context): + """ + Setup render window and any required change in the UI for this operator to run. + When returning from the function, the temporarty render view window is made + active in the current`context`. + """ + self.space_overrides = ValueOverrides() + + # Viewport rendering: find and setup a 3D view + if self.render_options.renderer == "VIEWPORT": + # Setup and use first available 3D view for viewport rendering + self.render_viewport_area = next( + (a for a in context.screen.areas if a.type == "VIEW_3D"), None + ) + + if not self.render_viewport_area: + raise RuntimeError("Viewport rendering requires a 3D view") + + self.render_viewport_window = context.window + + space = self.render_viewport_area.spaces.active + self.space_overrides.set(space, "show_gizmo", False) + self.space_overrides.set(space.overlay, "show_overlays", False) + self.space_overrides.set(space.shading, "type", "RENDERED") + self.space_overrides.set(space.shading, "use_scene_world_render", True) + self.space_overrides.set(space.shading, "use_scene_lights_render", True) + self.space_overrides.set(space.region_3d, "view_perspective", "CAMERA") + + # Temporarily change resolution for render view to have a fixed, pre-defined + # size. + render = context.scene.render + resolution_overrides = ValueOverrides() + r_ratio = render.resolution_y / render.resolution_x + win_height = int(self.RENDER_WINDOW_WIDTH * r_ratio) + resolution_overrides.set(render, "resolution_x", self.RENDER_WINDOW_WIDTH) + resolution_overrides.set(render, "resolution_y", win_height) + resolution_overrides.set(render, "resolution_percentage", 100) + + # Show the render window ourselves to identify it. + main_window = context.window + ctx = context.copy() + bpy.ops.render.view_show("INVOKE_DEFAULT") + + # If render view is not active at this point, it means that it was already open. + # This operator needs to run with this view active: try and close the existing + # render view. + if context.window == main_window: + for win in context.window_manager.windows: + # Filter out windows that are not temporary. + if "temp" not in win.screen.name: + continue + try: + # This operator will only succeed if the window and area + # match a render view setup. + bpy.ops.render.view_cancel( + {"window": win, "area": win.screen.areas[0]} + ) + # Re-open the render view + bpy.ops.render.view_show(ctx, "INVOKE_DEFAULT") + break + except RuntimeError: + pass + if context.window == main_window: + resolution_overrides.revert() + raise RuntimeError("Failed to create a render window") + + # Restore resolution settings now that the render view is open. + resolution_overrides.revert() + + self.render_window = context.window + + # Add a periodical timer event to ensure modal operator is evaluated + # without any user events. + # The chosen interval of 0.5 seconds is a good trade-off to get feedback without + # perceiving waiting time in between 2 tasks or at the end of the process. + self.render_event_timer = context.window_manager.event_timer_add( + 0.5, window=self.render_window + ) + + def close_render_window(self, context: bpy.types.Context): + """Close the render view and restore any UI changes made for rendering.""" + if not self.render_window: + return + + # Restore changes mades to the area + self.space_overrides.revert() + + # Remove event timer + context.window_manager.event_timer_remove(self.render_event_timer) + self.render_event_timer = None + + # Delay closing of render window to the next event loop (using a small interval of .1) + # for report message to be displayed and operator to finish correctly. + bpy.app.timers.register( + functools.partial(close_window, self.render_window), + first_interval=0.1, + ) + + def modal(self, context: bpy.types.Context, event: bpy.types.Event): + status = "FINISHED" + message = "Batch render done." + report_level = "INFO" + + try: + # Consume task and evalute its status. + self.consume_task_async(context) + if self.active_task: + # Task has been cancelled, cancel batch render. + if self.active_task.status == TaskStatus.CANCELLED: + raise RenderCancelled() + # Task has finished, clear it. + if self.active_task.status == TaskStatus.FINISHED: + self.clear_active_task() + # Otherwise, keep running modally. + return {"RUNNING_MODAL"} + except RenderCancelled: + message = "Batch render cancelled by user." + report_level = "WARNING" + status = "CANCELLED" + except Exception: + message = f"Batch Render failed. \n{traceback.format_exc()}" + report_level = "ERROR" + status = "CANCELLED" + + # Batch rendering has finished, cleanup remaining tasks if any. + self.cleanup() + + # Report operator result based on status + self.report({report_level}, message) + # Close render window + self.close_render_window(context) + + # Update global status. + self.render_props.status = status + + return {status} + + def consume_task_async(self, context: bpy.types.Context): + """Asynchronously consume next task in queue.""" + + # Start a new task if none is running. + if not self.active_task: + self.setup_next_task(context) + # No task left to do, return. + if not self.active_task: + return + + # An active task is setup but has not been started yet. + if self.active_task.status == TaskStatus.PENDING: + # NOTE: It might take several tries to actually start a task. + self.start_active_task(context) + + # Active task is still running. + elif self.active_task.status == TaskStatus.RUNNING: + if isinstance(self.active_task, BaseRenderTask): + # Ensure the entire rendered image is visible in the render window. + self.render_view_update() + + # Active task has finished, perform post run logic. + # NOTE: A task without having asynchronous logic would have finished immediatly + # after `start_active_task`. Test this status with an if statement + # (not an elif). + if self.active_task.status == TaskStatus.FINISHED: + self.active_task.post_run(context, self.render_options) + + def consume_task_sync(self, context: bpy.types.Context): + """Synchronously consume the next task in queue.""" + # Setup and make next task in queue active. + self.setup_next_task(context) + # Run the task. + self.start_active_task(context) + # Trigger task's post-run process. + self.active_task.post_run(context, self.render_options) + # Clear active task. + self.clear_active_task() + + def execute(self, context: bpy.types.Context): + # If operator has been invoked, it should have ran modally. + if self.options.is_invoke: + return {"FINISHED"} + + if context.scene.batch_render_options.renderer == "VIEWPORT": + self.report( + {"ERROR"}, "Blocking render incompatible with viewport rendering." + ) + return {"CANCELLED"} + + # Otherwise, run synchronously. + if not self.setup(context): + return {"CANCELLED"} + + self.render_props.status = "RUNNING" + + while self.tasks: + try: + self.consume_task_sync(context) + except Exception as e: + self.report( + {"ERROR"}, f"Batch Render failed. \n{traceback.format_exc()}" + ) + self.cancel(context) + return {"CANCELLED"} + + self.cleanup() + self.render_props.status = "FINISHED" + self.report({"INFO"}, "Batch render done!") + return {"FINISHED"} + + def setup_next_task(self, context): + """ + Setup the context for the next task in the tasks list. + After this call: + - this task won't be in the tasks list anymore. + - `self.active_task` will be set to this task. + """ + if self.active_task or not self.tasks: + return + + self.active_task = self.tasks.pop(0) + + self.active_task.setup(context, self.render_options) + + def start_active_task(self, context): + """Start the active task.""" + if not self.active_task or self.active_task.status != TaskStatus.PENDING: + return + self.active_task.run(context, self.render_options) + + def clear_tasks(self): + """Remove all the registered tasks.""" + self.clear_active_task() + self.tasks.clear() + # Reset global task count. + self.render_props.task_count = 0 + + def clear_active_task(self): + """Unassign and clear active task by restoring overriden values.""" + if not self.active_task: + return + self.active_task.teardown() + # Unassign active task + self.active_task = None + # Decrease global task count. + self.render_props.task_count -= 1 + + def cleanup(self): + """Clear all tasks, unregister app handlers and reset any override applied + to the scene.""" + self.clear_tasks() + # Revert global overrides + self.global_overrides.revert() + + +classes = (SEQUENCER_OT_batch_render,) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from typing import Callable + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes + + +MEDIA_TYPES_FORMATS = { + "IMAGES": ("JPEG", "jpg"), + "MOVIE": ("QUICKTIME", "mov"), +} + + +class BatchRenderOptions(bpy.types.PropertyGroup): + """Options for batch rendering stored at scene level (saved in file).""" + + # Python API for executing callbacks on tasks execution. + tasks_callbacks: dict[str, Callable] = {} + + media_type: bpy.props.EnumProperty( + name="Media Type", + description="The type of media to generate", + items=( + ("IMAGES", "Images", ""), + ("MOVIE", "Movie", ""), + ), + default="MOVIE", + options=set(), + ) + + renderer: bpy.props.EnumProperty( + name="Renderer", + description="The renderer to use", + items=( + ("INTERNAL", "Internal", "Use Internal rendering"), + ("VIEWPORT", "Viewport", "Use Viewport rendering"), + ), + default="INTERNAL", + options=set(), + ) + + render_engine: bpy.props.EnumProperty( + name="Engine", + description="The render engine to use", + items=( + ("BLENDER_EEVEE", "Eevee", "Eevee"), + ("BLENDER_WORKBENCH", "Workbench", "Workbench"), + ), + default="BLENDER_EEVEE", + options=set(), + ) + + resolution: bpy.props.EnumProperty( + name="Resolution", + description="Resolution scale factor", + items=( + ("100", "Original", "100%"), + ("50", "1/2", "50%"), + ("25", "1/4", "25%"), + ("12", "1/8", "12%"), + ), + default="100", + options=set(), + ) + + frames_handles: bpy.props.IntProperty( + name="Frames Handles", + description=( + "Extra frames to render before and after each strip's original range" + ), + default=0, + min=0, + soft_max=200, + options=set(), + ) + + output_scene: bpy.props.PointerProperty( + name="Output Scene", + description="The scene in which media strips from render should be created", + type=bpy.types.Scene, + ) + + output_auto_offset_channels: bpy.props.BoolProperty( + name="Auto Offset Channels", + description=( + "Offset strips channels in output scene to start on the first one " + "without content" + ), + default=False, + options=set(), + ) + + output_copy_sound_strips: bpy.props.BoolProperty( + name="Copy Sound Strips", + description="Copy sound strips from this sequencer to output scene", + default=False, + options=set(), + ) + + filepath_pattern: bpy.props.StringProperty( + name="Filepath Pattern", + description=( + "The filepath pattern to name media files.\n" + "Uses Python string formatting syntax. Available variables:\n" + " - filename: the base name of the Blender file without extension\n" + " - strip: the name of the strip\n" + " - scene: the name of the strip's scene\n" + ), + default="//render/{filename}/{strip}", + options=set(), + ) + + render_output_scene: bpy.props.BoolProperty( + name="Render Output Scene", + description="Whether to render the output scene as a single media", + default=False, + options=set(), + ) + + output_render_filepath_pattern: bpy.props.StringProperty( + name="Filepath Pattern", + description="The filepath pattern to name the output scene render", + default="//render/{filename}/{filename}", + options=set(), + ) + + selection_only: bpy.props.BoolProperty( + name="Selection Only", + description="Only render selected scene strips", + default=False, + options=set(), + ) + + def register_callback(self, task_name: str, callback: Callable): + """Register a post run callback for given task (task class name).""" + # TODO: improve logic to ensure task exists + self.tasks_callbacks[task_name] = callback + + def clear_callback(self, task_name: str): + """Un-register callback on given task.""" + if task_name in self.tasks_callbacks: + del self.tasks_callbacks[task_name] + + +class BatchRenderRuntimeProps(bpy.types.PropertyGroup): + """Batch Render runtime properties.""" + + status: bpy.props.EnumProperty( + name="Status", + description="Status of the batch render", + items=( + ("NONE", "None", "Batch render was not started yet"), + ("RUNNING", "Running", "Batch render is currently running"), + ("FINISHED", "Finished", "Batch render finished properly"), + ( + "CANCELLED", + "Cancelled", + "Batch render was interrupted (by the user or an error)", + ), + ), + default="NONE", + ) + + task_count: bpy.props.IntProperty( + name="Task Count", + description="Total number of render tasks", + default=0, + ) + + +classes = ( + BatchRenderOptions, + BatchRenderRuntimeProps, +) + + +def register(): + register_classes(classes) + + bpy.types.Scene.batch_render_options = bpy.props.PointerProperty( + type=BatchRenderOptions + ) + + bpy.types.WindowManager.batch_render = bpy.props.PointerProperty( + name="Batch Render", + type=BatchRenderRuntimeProps, + ) + + +def unregister(): + unregister_classes(classes) + del bpy.types.Scene.batch_render_options + del bpy.types.WindowManager.batch_render @@ -0,0 +1,517 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from dataclasses import dataclass, field +from enum import Enum, auto +import math +import os +from typing import Any, Callable, Optional +import bpy +from spa_sequencer.render.props import MEDIA_TYPES_FORMATS, BatchRenderOptions + +from spa_sequencer.sync.core import remap_frame_value + + +STRIP_PROP_SOURCE_BLENDER_FILE = "source_blender_file" +STRIP_PROP_SOURCE_SEQUENCER = "source_sequencer" +STRIP_PROP_SOURCE_STRIP = "source_strip" +STRIP_PROP_SOURCE_SCENE = "source_scene" +STRIP_PROP_SOURCE_CAMERA = "source_camera" +STRIP_PROP_SOURCE_FRAME_START = "source_frame_start" +STRIP_PROP_SOURCE_FRAME_END = "source_frame_end" + + +class ValueOverrides: + """Helper class to store attribute value changes and revert them easily.""" + + def __init__(self): + self._overrides: dict[object, list[tuple["str", Any]]] = {} + + def set(self, obj: object, attr, value): + """Override attribute `attr` on `obj` with `value`.""" + current_value = getattr(obj, attr) + if current_value == value: + return + # Store this change as an override + if obj not in self._overrides: + self._overrides[obj] = [] + self._overrides[obj].append((attr, current_value)) + setattr(obj, attr, value) + + def revert(self): + """Revert all registered overrides.""" + for obj, attrs in self._overrides.items(): + for key, value in attrs: + setattr(obj, key, value) + self._overrides.clear() + + +class TaskStatus(Enum): + PENDING = auto() + RUNNING = auto() + FINISHED = auto() + CANCELLED = auto() + + +@dataclass +class BaseTask: + """Base class for a task.""" + + # Task status. + status: TaskStatus = TaskStatus.PENDING + + # Value overrides associated to this task. + overrides: ValueOverrides = field(default_factory=ValueOverrides) + + def setup(self, context: bpy.types.Context, render_options: BatchRenderOptions): + """Setup the task.""" + pass + + def run(self, context: bpy.types.Context, render_options: BatchRenderOptions): + """Start the task and update its status.""" + pass + + def post_run(self, context: bpy.types.Context, render_options: BatchRenderOptions): + """Called after run completed successfully.""" + pass + + def teardown(self): + """Called when task has been consumed, whether it failed or succeeded.""" + self.overrides.revert() + + +@dataclass +class BaseRenderTask(BaseTask): + + is_modal: bool = True + handlers_registered: bool = False + + def setup(self, context, options): + self.register_render_handlers() + + def teardown(self): + super().teardown() + self.unregister_app_handlers() + + def on_render_completed(self, *args): + """Callback for `render_complete` handler.""" + self.status = TaskStatus.FINISHED + + def on_render_cancelled(self, *args): + """Callback for `render_cancel` handler.""" + self.status = TaskStatus.CANCELLED + + def register_render_handlers(self): + """Register render handlers callbacks.""" + bpy.app.handlers.render_cancel.append(self.on_render_cancelled) + bpy.app.handlers.render_complete.append(self.on_render_completed) + self.handlers_registered = True + + def unregister_app_handlers(self): + """Unregister render handlers callbacks.""" + if not self.handlers_registered: + return + + bpy.app.handlers.render_cancel.remove(self.on_render_cancelled) + bpy.app.handlers.render_complete.remove(self.on_render_completed) + self.handlers_registered = False + + @staticmethod + def conform_render_path(filepath: str) -> str: + # If blender file is not saved, move relative path to temp folder. + if not bpy.context.blend_data.filepath and filepath.startswith("//"): + return os.path.join(bpy.app.tempdir, bpy.path.abspath(filepath)) + return filepath + + @property + def render_op_exec_context(self) -> str: + """Get operator execution context based on app mode (background/interactive).""" + return "INVOKE_DEFAULT" if self.is_modal else "EXEC_DEFAULT" + + +""" +StripRenderTask callback: + - arguments: + - source scene strip + - rendered media filepath + - returns: + - path to the new media filepath +""" +StripRenderTaskCallback = Callable[[bpy.types.SceneSequence, str], str] + + +@dataclass +class StripRenderTask(BaseRenderTask): + """Strip render task.""" + + # The strip to render. + strip: Optional[bpy.types.SceneSequence] = None + # Viewport area. + viewport_area: Optional[bpy.types.Area] = None + # Window containing the viewport area. + viewport_window: Optional[bpy.types.Window] = None + # Output channel offset in output scene. + output_channel_offset: int = 0 + + @property + def scene(self) -> bpy.types.Scene: + """Get the internal strip's scene.""" + return self.strip.scene + + def setup(self, context: bpy.types.Context, render_options: BatchRenderOptions): + super().setup(context, render_options) + + strip = self.strip + scene = self.scene + + # Override scene's internal range to match strip's range. + frame_start = remap_frame_value(strip.frame_final_start, strip) + frame_end = frame_start + strip.frame_final_duration - 1 + # Apply frame handles to range. + if render_options.media_type == "MOVIE": + frame_start -= render_options.frames_handles + frame_end += render_options.frames_handles + + # Set render engine. + self.overrides.set(scene.render, "engine", render_options.render_engine) + + # Update both range (internal render) and preview range (viewport render) + self.overrides.set(scene, "frame_start", frame_start) + self.overrides.set(scene, "frame_end", frame_end) + self.overrides.set(scene, "frame_preview_start", frame_start) + self.overrides.set(scene, "frame_preview_end", frame_end) + + # Setup resolution values based on resolution scale setting. + # NOTE: Blender cannot render movies with odd width/height. + # Therefore, instead of using this as the resolution percentage directly, + # we compute resolution width and height to round up to the next even + # numbers. + + def round_up_to_even(value: float) -> int: + """Round `value` to next even number.""" + return int(math.ceil(value / 2.0) * 2) + + resolution_scale = int(render_options.resolution) / 100.0 + r_width = round_up_to_even(scene.render.resolution_x * resolution_scale) + r_height = round_up_to_even(scene.render.resolution_y * resolution_scale) + + self.overrides.set(scene.render, "resolution_x", r_width) + self.overrides.set(scene.render, "resolution_y", r_height) + self.overrides.set(scene.render, "resolution_percentage", 100) + self.overrides.set(scene.render, "use_sequencer", False) + + if strip.scene_camera: + self.overrides.set(scene, "camera", strip.scene_camera) + + # Resolve output media filepath based on configured pattern + variables = { + "strip": strip.name, + "scene": scene.name, + "filename": bpy.path.display_name_from_filepath(bpy.data.filepath), + } + filepath = render_options.filepath_pattern.format(**variables) + filepath = self.conform_render_path(filepath) + + # Override render settings based on media type + file_format, file_ext = MEDIA_TYPES_FORMATS[render_options.media_type] + render = scene.render + if render_options.media_type == "IMAGES": + # Filepath: add separator between resolved name and auto frame number suffix + filepath += "." + # Setup render settings + self.overrides.set(render.image_settings, "file_format", file_format) + self.overrides.set(render.image_settings, "quality", 100) + self.overrides.set(render.image_settings, "color_mode", "RGB") + else: + # Filepath: add extension to avoid auto frame range suffix + filepath += f".{file_ext}" + # Setup render settings + self.overrides.set(render.image_settings, "file_format", "FFMPEG") + self.overrides.set(render.ffmpeg, "format", file_format) + self.overrides.set(render.ffmpeg, "constant_rate_factor", "PERC_LOSSLESS") + # Setup final filepath + self.overrides.set(scene.render, "filepath", filepath) + + def run(self, context: bpy.types.Context, render_options: BatchRenderOptions): + overrides = {"scene": self.scene} + if self.viewport_area: + overrides["area"] = self.viewport_area + overrides["window"] = self.viewport_window + # We need to make the screen active in the window, overriding it + # does not work. + screen = self.viewport_area.id_data + self.viewport_window.screen = screen + # Make task's scene active in the window holding the viewport for rendering + # to happen correctly - context overriding is not enough in this case. + self.viewport_window.scene = self.scene + + # Determine render operator based on render options. + if render_options.renderer == "VIEWPORT": + render_op = bpy.ops.render.opengl + else: + render_op = bpy.ops.render.render + + # Start rendering the target scene. Use a context override for this to + # work both in interactive and background mode. + with context.temp_override(**overrides): + res = render_op(self.render_op_exec_context, animation=True) + + # Calling a render operator right after the previous task finishes may fail due + # to the ordering of events and how they are dealt with in Blender core. + # Therefore, we only consider the task as started if operator call returned the + # proper status (RUNNING_MODAL). + # See `modal` function to see how this function is used. + + if res == {"RUNNING_MODAL"}: + self.status = TaskStatus.RUNNING + + def post_run(self, context: bpy.types.Context, render_options: BatchRenderOptions): + if callback := render_options.tasks_callbacks.get(self.__class__.__name__, []): + # TODO: check callback compatibility + new_filepath = callback(self.strip, self.scene.render.filepath) + self.overrides.set(self.strip.scene.render, "filepath", new_filepath) + + if render_options.output_scene: + self.create_output_media_strip( + self.strip, + render_options.output_scene, + render_options.media_type, + render_options.frames_handles, + self.output_channel_offset, + render_options, + ) + + def create_output_media_strip( + self, + scene_strip: bpy.types.SceneSequence, + scene: bpy.types.Scene, + media_type: str, + frames_handles: int, + channel_offset: int, + render_options: BatchRenderOptions, + ): + """Create media strip(s) in output scene for the given scene strip. + + :param scene_strip: The source scene sequence strip. + """ + sed = scene.sequence_editor + + # List created strips and corresponding source frame start/end in scene + strips: list[tuple[bpy.types.SceneSequence, int, int]] = [] + + if media_type == "IMAGES": + for idx in range(scene_strip.frame_final_duration): + frame_number = scene_strip.scene.frame_start + idx + img_path = scene_strip.scene.render.frame_path(frame=frame_number) + strip = sed.sequences.new_image( + name=os.path.basename(bpy.path.abspath(img_path)), + filepath=img_path, + channel=scene_strip.channel + channel_offset, + frame_start=scene_strip.frame_final_start + idx, + ) + strips.append((strip, frame_number, frame_number)) + + elif media_type == "MOVIE": + filepath = scene_strip.scene.render.filepath + strip = sed.sequences.new_movie( + name=os.path.basename(bpy.path.abspath(filepath)), + filepath=filepath, + channel=scene_strip.channel + channel_offset, + frame_start=scene_strip.frame_final_start, + ) + + start_handle = frames_handles + if frames_handles > 0 and render_options.renderer == "INTERNAL": + # Internal render does not support negative frame rendering. + # We therefore need to compute the real start handle duration. + # This is done by getting the difference between: + # the rendered clip duration + # and the scene strip's duration + end frame handle's duration. + start_handle = strip.frame_duration - ( + scene_strip.frame_final_duration + frames_handles + ) + + strip.frame_offset_start = start_handle + strip.frame_offset_end = frames_handles + strip.frame_start -= start_handle + strips.append( + (strip, scene_strip.scene.frame_start, scene_strip.scene.frame_end) + ) + + for strip, frame_start, frame_end in strips: + # Re-assign the strip channel to force Blender to evalute overlaps + strip.channel = strip.channel + # Store render source metadata as custom properties on the strip. + original_edit_scene_name = self.strip.id_data.name + strip[STRIP_PROP_SOURCE_BLENDER_FILE] = bpy.data.filepath + strip[STRIP_PROP_SOURCE_SEQUENCER] = original_edit_scene_name + strip[STRIP_PROP_SOURCE_STRIP] = scene_strip.name + strip[STRIP_PROP_SOURCE_SCENE] = scene_strip.scene.name + strip[STRIP_PROP_SOURCE_CAMERA] = scene_strip.scene.camera.name + strip[STRIP_PROP_SOURCE_FRAME_START] = frame_start + strip[STRIP_PROP_SOURCE_FRAME_END] = frame_end + + +@dataclass +class CopySoundStripsTask(BaseTask): + src_scene: Optional[bpy.types.Scene] = None + dst_scene: Optional[bpy.types.Scene] = None + sound_strips: list[bpy.types.SoundSequence] = field(default_factory=list) + + def run(self, context: bpy.types.Context, render_options: BatchRenderOptions): + self.status = TaskStatus.FINISHED + + def post_run(self, context, render_options): + if not self.src_scene or not self.dst_scene or not self.sound_strips: + return + + sed_src = self.src_scene.sequence_editor + sed_dst = self.dst_scene.sequence_editor + + # Store original selection from source and dest scenes. + original_selection = [ + seq for seq in sed_src.sequences[:] + sed_dst.sequences[:] if seq.select + ] + + # Select only sound strips in source scene. + with context.temp_override(scene=self.src_scene): + bpy.ops.sequencer.select_all(action="DESELECT") + + for seq in self.sound_strips: + seq.select = True + + # Copy strips from source. + # Build context with scene and selected sequences (must match actual selection). + with context.temp_override( + scene=self.src_scene, selected_sequences=self.sound_strips + ): + bpy.ops.sequencer.copy() + + paste_op_ctx_override = {"scene": self.dst_scene} + # Paste strips to dest. + # Override area type to be a sequence editor for paste operator to work. + if not (area := context.area): + # Context does not have an area in background mode. + # In that case, just use the first available area and make it active + # using context override. + window = context.window_manager.windows[0] + screen = window.screen + area = screen.areas[0] + paste_op_ctx_override.update( + { + "window": window, + "screen": screen, + } + ) + # CBB: This should not be necessary, but for some reason this is needed + # for sequencer.paste to work with the correct scene... + self.overrides.set(window, "scene", self.dst_scene) + + paste_op_ctx_override["area"] = area + + self.overrides.set(area, "type", "SEQUENCE_EDITOR") + # Use sequencer editor's "WINDOW" region for operators to work properly. + paste_op_ctx_override["region"] = next( + r for r in area.regions if r.type == "WINDOW" + ) + # Paster operator does not play well with sounds starting before frame 0. + # To handle pasting such strips, make the copy happen at frame 0, and slide them + # from the proper offset afterwards. + self.overrides.set(self.dst_scene, "frame_current", 0) + frame_offset = self.sound_strips[0].frame_final_start + + try: + # Call paste operator with scene context override. + with context.temp_override(**paste_op_ctx_override): + bpy.ops.sequencer.paste() + bpy.ops.transform.seq_slide(value=(frame_offset, 0)) + finally: + self.overrides.revert() + + # Restore original selection. + with context.temp_override(scene=self.src_scene): + bpy.ops.sequencer.select_all(action="DESELECT") + for seq in original_selection: + seq.select = True + + +@dataclass +class FitResolutionToContentTask(BaseTask): + + scene: Optional[bpy.types.Scene] = None + + def run(self, context: bpy.types.Context, render_options: BatchRenderOptions): + self.status = TaskStatus.FINISHED + + def post_run(self, context: bpy.types.Context, render_options: BatchRenderOptions): + """Fit output scene resolution to visual content.""" + + if not self.scene: + return + + img_seqs = [ + s + for s in self.scene.sequence_editor.sequences + if isinstance(s, (bpy.types.MovieSequence, bpy.types.ImageSequence)) + ] + + if not img_seqs: + return + + # Get maximum width and height of visual strips. + width = max(seq.elements[0].orig_width for seq in img_seqs) + height = max(seq.elements[0].orig_height for seq in img_seqs) + + self.scene.render.resolution_x = width + self.scene.render.resolution_y = height + self.scene.render.resolution_percentage = 100 + + +@dataclass +class SequenceRenderTask(BaseRenderTask): + scene: Optional[bpy.types.Scene] = None + + def setup(self, context: bpy.types.Context, render_options: BatchRenderOptions): + if not self.scene: + return + + super().setup(context, render_options) + + # Resolve filename and fallback to default naming if current file is not saved. + filename = bpy.path.display_name_from_filepath(bpy.data.filepath) or "out" + # Resolve output media filepath based on configured pattern + variables = { + "scene": self.scene.name, + "filename": filename, + } + filepath = render_options.output_render_filepath_pattern.format(**variables) + filepath = self.conform_render_path(filepath) + + # Override render settings based on media type + file_format, file_ext = MEDIA_TYPES_FORMATS["MOVIE"] + + # Only consider range of video media types. + sequences = [ + s + for s in self.scene.sequence_editor.sequences + if isinstance(s, (bpy.types.MovieSequence, bpy.types.ImageSequence)) + ] + + self.scene.frame_start = min(s.frame_final_start for s in sequences) + self.scene.frame_end = max(s.frame_final_end for s in sequences) - 1 + + # Filepath: add extension to avoid auto frame range suffix + filepath += f".{file_ext}" + # Setup render settings + render = self.scene.render + self.overrides.set(render.image_settings, "file_format", "FFMPEG") + self.overrides.set(render.ffmpeg, "format", file_format) + self.overrides.set(render.ffmpeg, "constant_rate_factor", "PERC_LOSSLESS") + self.overrides.set(render.ffmpeg, "audio_codec", "AAC") + self.overrides.set(render, "filepath", filepath) + + def run(self, context, options): + with context.temp_override(scene=self.scene): + res = bpy.ops.render.render(self.render_op_exec_context, animation=True) + if res == {"RUNNING_MODAL"}: + self.status = TaskStatus.RUNNING @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes + + +class SEQUENCER_PT_batch_render(bpy.types.Panel): + bl_label = "Batch Render" + bl_space_type = "SEQUENCE_EDITOR" + bl_region_type = "UI" + bl_category = "SPA.Sequencer" + + def draw(self, context: bpy.types.Context): + self.layout.use_property_split = True + options = context.scene.batch_render_options + self.layout.prop(options, "media_type") + self.layout.prop(options, "renderer") + self.layout.prop(options, "render_engine") + self.layout.prop(options, "resolution") + if options.media_type == "MOVIE": + self.layout.prop(options, "frames_handles") + + self.layout.prop(options, "filepath_pattern") + self.layout.prop(options, "selection_only") + box = self.layout.box() + box.prop(options, "output_scene") + if options.output_scene: + col = box.column(align=True) + col.prop(options, "output_auto_offset_channels") + col.prop(options, "output_copy_sound_strips") + col.prop(options, "render_output_scene") + if options.render_output_scene: + col.prop(options, "output_render_filepath_pattern") + self.layout.operator("sequencer.batch_render") + + +classes = (SEQUENCER_PT_batch_render,) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from spa_sequencer.sequence import ( + overlay, + props, + ops, + ui, +) + + +def register(): + props.register() + ops.register() + overlay.register() + ui.register() + + +def unregister(): + props.unregister() + ops.unregister() + overlay.unregister() + ui.unregister() @@ -0,0 +1,264 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes +from spa_sequencer.sync.core import ( + get_sync_master_strip, + get_sync_settings, + remap_frame_value, + sync_system_update, +) + + +class DOPESHEET_OT_sequence_navigate(bpy.types.Operator): + bl_idname = "dopesheet.sequence_navigate" + bl_label = "Navigate Sequence" + bl_description = "Navigate master sequence" + bl_options = {"UNDO", "BLOCKING", "INTERNAL"} + + frame: bpy.props.IntProperty( + name="Frame", + description="Frame value", + default=-1, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return get_sync_settings().master_scene is not None + + def modal(self, context: bpy.types.Context, event: bpy.types.Event): + frame_value = int( + context.region.view2d.region_to_view(event.mouse_region_x, 0)[0] + ) + if frame_value != self.frame: + self.frame = frame_value + self.execute(context) + + # Validate + if event.type in {"LEFTMOUSE"} and event.value in {"RELEASE"}: + return {"FINISHED"} + + return {"RUNNING_MODAL"} + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + + def execute(self, context: bpy.types.Context): + master_scene = get_sync_settings().master_scene + master_strip, _ = get_sync_master_strip() + + # Find a strip that matches the timing + strips = [ + s + for s in master_scene.sequence_editor.sequences + if isinstance(s, bpy.types.SceneSequence) and s.scene == bpy.context.scene + ] + + candidates = [ + s + for s in strips + if ( + remap_frame_value(s.frame_final_start, s) + <= self.frame + <= remap_frame_value(s.frame_final_end, s) + ) + ] + + strip = next( + (s for s in candidates if s == master_strip), next(iter(candidates), None) + ) + + # Update master scene current frame to enter target strip. + if strip and strip != master_strip: + master_scene.frame_set(strip.frame_final_start) + + # Set frame_current directly for context's active scene. + # This proves to be enough and reacts better than frame_set which + # might discard the change when playback is running. + context.scene.frame_current = self.frame + + return {"FINISHED"} + + +class SEQUENCE_OT_active_shot_camera_set(bpy.types.Operator): + bl_idname = "sequence.active_shot_camera_set" + bl_label = "Set Active Shot Camera" + bl_description = "Set active shot camera" + bl_options = {"UNDO"} + + camera: bpy.props.StringProperty( + name="Camera", + description="Scene to set on active shot", + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return get_sync_master_strip(use_cache=True)[0] is not None + + def execute(self, context: bpy.types.Context): + strip = get_sync_master_strip(use_cache=True)[0] + cam = bpy.data.objects.get(self.camera, None) + # Update strip's camera. + strip.scene_camera = cam + # Set this camera active in underlying scene. + strip.scene.camera = cam + return {"FINISHED"} + + +class SEQUENCE_OT_active_shot_scene_set(bpy.types.Operator): + bl_idname = "sequence.active_shot_scene_set" + bl_label = "Set Active Shot Scene" + bl_description = "Set active shot scene" + bl_options = {"UNDO"} + + scene: bpy.props.StringProperty( + name="Scene", + description="Scene to set on active shot", + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return get_sync_master_strip(use_cache=True)[0] is not None + + def execute(self, context: bpy.types.Context): + strip = get_sync_master_strip(use_cache=True)[0] + scene = bpy.data.scenes.get(self.scene, None) + + strip.scene = scene + + # Update strip's camera to use target scene's active camera. + if not strip.scene_camera or ( + strip.scene_camera.name not in scene.collection.all_objects + ): + strip.scene_camera = scene.camera + + # Force sync system to update to account for this change. + sync_system_update(context, force=True) + return {"FINISHED"} + + +class SEQUENCE_OT_check_obj_users_scene(bpy.types.Operator): + bl_idname = "sequence.check_obj_users_scene" + bl_label = "Report Object use by Sequencer" + bl_description = ( + "From the active object, report all sequencer strips using " + "this object in their strip scenes" + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return context.active_object and get_sync_master_strip(use_cache=True)[0] + + def build_obj_user_scene_report(self, obj): + info_msg = "" + master_scene = get_sync_settings().master_scene + strips = [ + strip + for strip in master_scene.sequence_editor.sequences_all + if strip.type == "SCENE" + ] + + for strip in sorted(strips, key=lambda f: f.frame_final_start): + scene_msg = f" - Scene '{strip.scene.name}' from strips:\n" + if scene_msg not in info_msg: + info_msg += scene_msg + info_msg += ( + f" - {strip.name} " + f"[{strip.frame_final_start}, {strip.frame_final_end}]\n" + ) + + report = f"Object '{obj.name}' is used in '{master_scene.name}' by:\n{info_msg}" + return report + + def invoke(self, context, event): + self.setup_name = context.scene.name + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + report = self.build_obj_user_scene_report(context.active_object) + report_lines = report.split("\n") + col = self.layout.column() + for line in report_lines: + col = self.layout.column() + col.label(text=line) + + def execute(self, context: bpy.types.Context): + report = self.build_obj_user_scene_report(context.active_object) + self.report({"INFO"}, report) + return {"FINISHED"} + + +class SEQUENCE_OT_child_scene_setup_create(bpy.types.Operator): + bl_idname = "sequence.child_scene_setup_create" + bl_label = "Create Child Scene Setup" + bl_description = "From the current scene, make a new scene and add a new collection" + bl_property = "setup_name" + bl_options = {"UNDO"} + + @classmethod + def poll(cls, context: bpy.types.Context): + return get_sync_master_strip(use_cache=True)[0] + + setup_name: bpy.props.StringProperty(name="Name") + + def invoke(self, context, event): + self.setup_name = context.scene.name + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + col = layout.column() + col.prop(self, "setup_name") + + def execute(self, context: bpy.types.Context): + # Cancel if user provides a scene name that is already used + if self.setup_name in bpy.data.scenes: + self.report({"ERROR"}, "Name already in use") + return {"CANCELLED"} + # Get current strip it's scene as a reference + strip = get_sync_master_strip(use_cache=True)[0] + ref_scene = strip.scene + # Create new Scene and set name + with context.temp_override(scene=ref_scene): + bpy.ops.scene.new(type="LINK_COPY") + new_scene = context.scene + new_scene.name = self.setup_name + # create pointer back to old scene + new_scene.parent_scene = ref_scene + + # Create new collection with the same name + new_setup_collection = bpy.data.collections.new(name=self.setup_name) + new_scene.collection.children.link(new_setup_collection) + + # Assign new scene to current strip + strip.scene = new_scene + + # Report to user new scene creation and assignment + self.report( + {"INFO"}, + f"New scene setup '{self.setup_name}' created on strip '{strip.name}'", + ) + return {"FINISHED"} + + +classes = ( + SEQUENCE_OT_check_obj_users_scene, + DOPESHEET_OT_sequence_navigate, + SEQUENCE_OT_active_shot_camera_set, + SEQUENCE_OT_active_shot_scene_set, + SEQUENCE_OT_child_scene_setup_create, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,365 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy +import blf +import mathutils + +from spa_sequencer.sync.core import ( + get_sync_master_strip, + get_sync_settings, + remap_frame_value, +) + +from spa_sequencer.gpu_utils import Vec4f, OverlayDrawer +from spa_sequencer.utils import register_classes, unregister_classes + + +# - Overlay UI global settings + +# Height of a shot strip item +STRIP_HEIGHT = 20 +# Vertical offset for active shot +ACTIVE_SHOT_Y_OFFSET = STRIP_HEIGHT * 0.5 +# Height of the timeline area +TIMELINE_HEIGHT = STRIP_HEIGHT + 8 +# Width of strip handles +HANDLE_WIDTH: int = 1 +HANDLE_WIDTH_ACTIVE: int = 4 +# Minimum height of the region to display the overlay +MIN_REGION_HEIGHT = TIMELINE_HEIGHT * 4 + +# Colors +STRIP_COLOR_BASE: Vec4f = (0.1, 0.1, 0.1, 0.5) +STRIP_COLOR_ACTIVE: Vec4f = (0.1, 0.1, 0.1, 0.8) +HANDLE_COLOR_BASE: Vec4f = (0.3, 0.3, 0.3, 0.7) +HANDLE_COLOR_LEFT_ACTIVE: Vec4f = (0.3, 0.95, 0.4, 0.6) +HANDLE_COLOR_RIGHT_ACTIVE: Vec4f = (0.95, 0.3, 0.4, 0.6) +TEXT_COLOR_BASE: Vec4f = (0.8, 0.8, 0.8, 0.7) +TEXT_COLOR_ACTIVE: Vec4f = (0.8, 0.8, 0.8, 0.9) + + +def ui_scaled(val): + """Return value multiplied by UI scale factor.""" + return val * bpy.context.preferences.system.ui_scale + + +def ui_baseline_y_pos(context): + return ui_scaled(45 if context.scene.timeline_markers else 14) + + +def shot_baseline_y_pos(context): + return ui_baseline_y_pos(context) + ui_scaled(TIMELINE_HEIGHT - STRIP_HEIGHT) * 0.5 + + +def draw_shot_strip( + region: bpy.types.Region, + drawer: OverlayDrawer, + strip: bpy.types.SceneSequence, + active: bool = False, +): + """ + Draw a shot `strip` in the given `region`. + + :param region: The draw region. + :param drawer: A PolyDrawer instance. + :param strip: The shot strip to draw. + :param active: Whether this shot strip is the active one. + """ + strip_height = ui_scaled(STRIP_HEIGHT) + base_y_pos = shot_baseline_y_pos(bpy.context) + + frame_in = remap_frame_value(strip.frame_final_start, strip) + frame_out = remap_frame_value(strip.frame_final_end, strip) + frame_in = region.view2d.view_to_region(frame_in, 0, clip=False)[0] + frame_out = region.view2d.view_to_region(frame_out, 0, clip=False)[0] + duration = frame_out - frame_in + y = base_y_pos + (ui_scaled(ACTIVE_SHOT_Y_OFFSET) if active else 0) + + strip_col = STRIP_COLOR_ACTIVE if active else STRIP_COLOR_BASE + handle_l_col = HANDLE_COLOR_LEFT_ACTIVE if active else HANDLE_COLOR_BASE + handle_r_col = HANDLE_COLOR_RIGHT_ACTIVE if active else HANDLE_COLOR_BASE + handle_width = ui_scaled(HANDLE_WIDTH_ACTIVE) if active else ui_scaled(HANDLE_WIDTH) + + # Strip + drawer.draw_rect(frame_in, y, duration, strip_height, strip_col) + # Left handle + drawer.draw_rect(frame_in, y, handle_width, strip_height, handle_l_col) + # Right handle + drawer.draw_rect( + frame_out - handle_width, y, handle_width, strip_height, handle_r_col + ) + + # Shot name + font_id = 0 + blf.color(font_id, *(TEXT_COLOR_ACTIVE if active else TEXT_COLOR_BASE)) + blf.size(font_id, int(11 * bpy.context.preferences.system.ui_scale), 72) + + # Compute text dimensions for horizontal centering + dims = blf.dimensions(0, strip.name) + padding = handle_width + 2 + blf.position(font_id, frame_in + padding, y + (strip_height - dims[1]) * 0.5, 0) + blf.enable(0, blf.CLIPPING) + blf.clipping(font_id, frame_in, y, frame_out - padding, y + strip_height) + blf.draw(font_id, strip.name) + blf.disable(0, blf.CLIPPING) + + +def draw_sequence_overlay_cb(drawer: OverlayDrawer): + """ + Draw master sequence strips using the current scene in the dopesheet. + + :param drawer: PolyDrawer instance. + """ + context = bpy.context + sync_settings = get_sync_settings() + sequence_settings = context.window_manager.sequence_settings + + # Early return if sync or overlay options are disabled. + if not sync_settings.enabled or not sequence_settings.overlay_dopesheet: + return + + # Only draw overlay if current scene matches master strip's scene. + master_strip = get_sync_master_strip(use_cache=True)[0] + if not master_strip or master_strip.scene != context.scene: + return + + if context.region.height < MIN_REGION_HEIGHT: + return + + # Draw 1 frame duration at current time. + # This helps getting a better sense of the frame's extent. + frame = context.scene.frame_current + f0 = context.region.view2d.view_to_region(frame, 0, clip=False)[0] + f1 = context.region.view2d.view_to_region(frame + 1, 0, clip=False)[0] + drawer.draw_rect( + f0, + ui_baseline_y_pos(bpy.context), + f1 - f0, + ui_scaled(TIMELINE_HEIGHT), + (0.1, 0.5, 0.8, 0.6), + ) + + # List strips using the currently active scene in the master sequence timeline + scene_strips = [ + s + for s in sync_settings.master_scene.sequence_editor.sequences + if isinstance(s, bpy.types.SceneSequence) + and s.scene == context.scene + and s != master_strip + ] + + # Draw those strips + for strip in scene_strips: + draw_shot_strip(context.region, drawer, strip) + + # Draw master strip on top + draw_shot_strip(context.region, drawer, master_strip, active=True) + + +class GIZMO_GT_Rectangle(bpy.types.Gizmo): + """Unit rectangular area, with origin at bottom left corner.""" + + def draw(self, context): + self.draw_custom_shape(self.custom_shape) + + def setup(self): + bl, br, tr, tl = (0, 0), (0, 1), (1, 1), (1, 0) + self.custom_shape = self.new_custom_shape("TRIS", (bl, br, tr, bl, tr, tl)) + + +class GIZMO_GT_MouseArea(GIZMO_GT_Rectangle): + """Unit rectangle area reacting to mouse events.""" + + cursor: bpy.props.StringProperty( + name="CURSOR", + description="Mouse cursor for this area", + default="", + ) + + def test_select(self, context, v): + # Compute rectangle area in world coordinates + tl = self.matrix_world @ mathutils.Vector((0, 1, 0, 1)) + br = self.matrix_world @ mathutils.Vector((1, 0, 0, 1)) + # Rectangle/point intersection test + res = (tl[0] <= v[0] <= br[0]) and (br[1] <= v[1] <= tl[1]) + if self.cursor: + if res: + context.window.cursor_set(self.cursor) + else: + context.window.cursor_set("DEFAULT") + return 0 if res else -1 + + +class DOPESHEET_GGT_SequenceGizmos(bpy.types.GizmoGroup): + bl_label = "Sequence Gizmos" + bl_options = {"PERSISTENT", "SHOW_MODAL_ALL", "SCALE"} + bl_space_type = "DOPESHEET_EDITOR" + bl_region_type = "WINDOW" + + @classmethod + def poll(cls, context: bpy.types.Context): + master_strip = get_sync_master_strip(use_cache=True)[0] + return ( + context.window_manager.sequence_settings.overlay_dopesheet + and master_strip + and master_strip.scene == context.scene + ) + + @staticmethod + def set_gizmo_geom(gizmo: bpy.types.Gizmo, x, y, width, height): + gizmo.matrix_basis[0][3] = x + gizmo.matrix_basis[1][3] = y + gizmo.matrix_basis[0][0] = width + gizmo.matrix_basis[1][1] = height + + def draw_prepare(self, context: bpy.types.Context): + # Safety check: gizmos must be initialized + if not self.gizmos: + return + + region = context.region + if context.region.height < MIN_REGION_HEIGHT: + for gz in self.gizmos: + if not gz.hide: + gz.hide = True + return + + for gz in self.gizmos: + if gz.hide: + gz.hide = False + + base_y = ui_baseline_y_pos(context) + self.set_gizmo_geom( + self.sequence_timeline, + x=0, + y=base_y, + width=region.width, + height=ui_scaled(TIMELINE_HEIGHT), + ) + + strip, _ = get_sync_master_strip(use_cache=True) + + # Toggle strip handles based on whether there is an active master strip + for gizmo in (self.right_handle, self.left_handle, self.shot_handle): + gizmo.hide = not strip + # Early return if not active strip + if not strip: + return + + frame_in = remap_frame_value(strip.frame_final_start, strip) + frame_out = remap_frame_value(strip.frame_final_end, strip) + frame_in = region.view2d.view_to_region(frame_in, 0, clip=False)[0] + frame_out = region.view2d.view_to_region(frame_out, 0, clip=False)[0] + + handle_width = ui_scaled(HANDLE_WIDTH_ACTIVE) * 2 + strip_height = ui_scaled(STRIP_HEIGHT) + strip_y = shot_baseline_y_pos(context) + ui_scaled(ACTIVE_SHOT_Y_OFFSET) + + # Left handle + self.set_gizmo_geom( + self.left_handle, + x=frame_in - handle_width * 0.5, + y=strip_y, + width=handle_width, + height=strip_height, + ) + + # Right handle + self.set_gizmo_geom( + self.right_handle, + x=frame_out - handle_width * 0.5, + y=strip_y, + width=handle_width, + height=strip_height, + ) + + strip_handle_height = strip_height * 0.5 + # Shot handle + self.set_gizmo_geom( + self.shot_handle, + x=frame_in, + y=strip_y + strip_height - strip_handle_height * 0.5, + width=frame_out - frame_in, + height=strip_handle_height, + ) + + def add_gizmo(self, operator: str): + gizmo = self.gizmos.new("GIZMO_GT_MouseArea") + gizmo.cursor = "MOVE_X" + gizmo.color = gizmo.color_highlight = 0.4, 0.4, 0.4 + gizmo.alpha = 0.0 + gizmo.alpha_highlight = 0.7 + gizmo.use_draw_modal = True + gizmo.use_draw_scale = False + + props = gizmo.target_set_operator(operator) + return gizmo, props + + def setup(self, context): + # Active shot gizmos + # - Left handle + self.left_handle, props = self.add_gizmo("sequencer.shot_timing_adjust") + props.strip_handle = "LEFT" + # - Right handle + self.right_handle, props = self.add_gizmo("sequencer.shot_timing_adjust") + props.strip_handle = "RIGHT" + # - Shot handle (slip content) + self.shot_handle, props = self.add_gizmo("sequencer.shot_timing_adjust") + self.shot_handle.alpha = 0.2 + self.shot_handle.alpha_highlight = 0.7 + props.mode = "SLIP" + + # Sequence timeline scrub gizmo + self.sequence_timeline, props = self.add_gizmo("dopesheet.sequence_navigate") + self.sequence_timeline.cursor = "" + self.sequence_timeline.color = 0.17, 0.17, 0.17 + self.sequence_timeline.alpha = 0.8 + self.sequence_timeline.alpha_highlight = 0.8 + self.sequence_timeline.color_highlight = self.sequence_timeline.color + + +# Global handle object to store registered custom overlay draw callback +draw_cb_handle = [] + + +def enable_sequence_overlay(): + # Discard overlay in background mode or if already registered + if bpy.app.background or draw_cb_handle: + return + + # Create a PolyDrawer instance for custom shape drawing + drawer = OverlayDrawer() + # Register the sequence overlay draw callback in dopesheet based editors + draw_cb_handle[:] = [ + bpy.types.SpaceDopeSheetEditor.draw_handler_add( + draw_sequence_overlay_cb, (drawer,), "WINDOW", "POST_PIXEL" + ) + ] + + +def disable_sequence_overlay(): + # Discard if draw callback has not been registered + if not draw_cb_handle: + return + # Remove sequence overlay draw callback + bpy.types.SpaceDopeSheetEditor.draw_handler_remove(draw_cb_handle[0], "WINDOW") + # Clear global handle object + draw_cb_handle.clear() + + +classes = ( + GIZMO_GT_Rectangle, + GIZMO_GT_MouseArea, + DOPESHEET_GGT_SequenceGizmos, +) + + +def register(): + enable_sequence_overlay() + register_classes(classes) + + +def unregister(): + disable_sequence_overlay() + unregister_classes(classes) @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.sync.core import get_sync_settings + +from spa_sequencer.utils import register_classes, unregister_classes + + +class SequenceSettings(bpy.types.PropertyGroup): + """ + Sequence related settings. + """ + + overlay_dopesheet: bpy.props.BoolProperty( + name="Dopesheet Overlay", + description="Display Sequence timeline overlay in dopesheet editors", + default=True, + ) + + def shot_active_index_get_cb(self): + """Get sequence active shot index.""" + return get_sync_settings().last_master_strip_idx + + def shot_active_index_set_cb(self, idx): + """Set sequence active shot index.""" + # Move to beginning of shot strip in master scene. + scene = get_sync_settings().master_scene + frame = scene.sequence_editor.sequences[idx].frame_final_start + scene.frame_set(frame) + + shot_active_index: bpy.props.IntProperty( + name="Active Shot Index", + description="Index of the active shot in sequence", + get=shot_active_index_get_cb, + set=shot_active_index_set_cb, + options=set(), + ) + + +class SequencePanelSettings(bpy.types.PropertyGroup): + camera_viewport_is_expanded: bpy.props.BoolProperty( + name="Expand 'Viewport Display' panel", default=False + ) + camera_dof_is_expanded: bpy.props.BoolProperty( + name="Expand 'Depth of Field' panel", default=False + ) + camera_bg_is_expanded: bpy.props.BoolProperty( + name="Expand 'Background Image' panel", default=False + ) + + +classes = (SequenceSettings, SequencePanelSettings) + + +def register(): + register_classes(classes) + + bpy.types.WindowManager.sequence_settings = bpy.props.PointerProperty( + type=SequenceSettings, + name="Sequence Settings", + ) + + bpy.types.WindowManager.sequence_panel_settings = bpy.props.PointerProperty( + type=SequencePanelSettings, + name="Sequence Panel Settings", + ) + bpy.types.Scene.parent_scene = bpy.props.PointerProperty( + name="Parent Scene", + type=bpy.types.Scene, + description="Parent of the current scene, collections from parent scene are shared into child scene by default", + ) + + +def unregister(): + del bpy.types.WindowManager.sequence_settings + del bpy.types.WindowManager.sequence_panel_settings + del bpy.types.Scene.parent_scene + + unregister_classes(classes) @@ -0,0 +1,236 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.shot.core import ( + get_scene_cameras, + get_valid_shot_scenes, +) + +from spa_sequencer.sync.core import ( + get_sync_master_strip, + get_sync_settings, +) + +from spa_sequencer.utils import register_classes, unregister_classes + + +class DOPESHEET_PT_Sequence(bpy.types.Panel): + bl_label = "Sequence" + bl_space_type = "DOPESHEET_EDITOR" + bl_region_type = "UI" + bl_category = "SPA.Sequencer" + + @classmethod + def poll(cls, context: bpy.types.Context): + return get_sync_settings().enabled + + def draw(self, context: bpy.types.Context): + self.layout.prop( + context.window_manager.sequence_settings, + "overlay_dopesheet", + text="Timeline Overlay", + ) + + +class SEQUENCE_MT_active_shot_camera_select(bpy.types.Menu): + bl_idname = "SHOT_MT_camera_select" + bl_label = "Camera" + bl_description = "Select active shot's camera" + + def draw(self, context): + strip, _ = get_sync_master_strip(True) + + # Draw a menu entry foreach camera in the scene. + for cam in get_scene_cameras(strip.scene): + props = self.layout.operator( + "sequence.active_shot_camera_set", + text=cam.name, + icon="OUTLINER_OB_CAMERA" if strip.scene_camera == cam else "NONE", + ) + props.camera = cam.name + + +class SEQUENCE_MT_active_shot_scene_select(bpy.types.Menu): + bl_idname = "SHOT_MT_scene_select" + bl_label = "Scene" + bl_description = "Select active shot's scene" + + def draw(self, context): + strip, _ = get_sync_master_strip(True) + # Draw a menu entry for each valid shot scene. + for scene in get_valid_shot_scenes(): + props = self.layout.operator( + "sequence.active_shot_scene_set", + text=scene.name, + icon="SCENE_DATA" if strip.scene == scene else "NONE", + ) + props.scene = scene.name + + +class SEQUENCE_UL_shot(bpy.types.UIList): + bl_idname = "SEQUENCE_UL_shot" + + def draw_item( + self, context, layout, data, item, icon, active_data, active_propname + ): + row = layout.row(align=True) + subrow = row.row() + + subrow.prop(item, "name", text="", emboss=False) + subrow.ui_units_x = 8 + + icon = ( + "OUTLINER_OB_CAMERA" + if item.scene_camera == context.scene.camera + else "BLANK1" + ) + camera_name = item.scene_camera.name if item.scene_camera else "Active" + subrow = row.row(align=True) + subrow.alignment = "EXPAND" + subrow.label(text=camera_name, icon=icon) + sub = row.row() + icon = "SCENE_DATA" if item.scene == context.scene else "BLANK1" + sub.alignment = "RIGHT" + sub.label(text=item.scene.name, icon=icon) + + def filter_items(self, context, data, propname): + objects = getattr(data, propname) + helper_funcs = bpy.types.UI_UL_list + + # Keep only scene strips. + flt_flags = [ + self.bitflag_filter_item if isinstance(obj, bpy.types.SceneSequence) else 0 + for obj in objects + ] + flt_neworder = [] + + # Prepare data for sorting and perform sort + sort_data = [(idx, obj.frame_final_start) for idx, obj in enumerate(objects)] + flt_neworder = helper_funcs.sort_items_helper(sort_data, key=lambda obj: obj[1]) + + return flt_flags, flt_neworder + + +class VIEW3D_PT_sequence(bpy.types.Panel): + bl_label = "Sequence" + bl_category = "SPA.Sequencer" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + + def draw_header_preset(self, context: bpy.types.Context): + self.layout.prop( + context.window_manager.timeline_sync_settings, + "master_scene", + text="", + icon="SEQ_STRIP_DUPLICATE", + ) + + def draw(self, context): + self.layout.use_property_split = True + master_scene = get_sync_settings().master_scene + if ( + not master_scene + or not master_scene.sequence_editor + or not master_scene.sequence_editor.sequences + ): + return + + # Draw shot lists in the master sequence. + self.draw_shots_list(context, master_scene.sequence_editor) + # Draw active shot details if any. + if strip := get_sync_master_strip(use_cache=True)[0]: + self.draw_shot_strip(context, strip) + + def draw_shots_list(self, context, sed): + """Draw shot list in the given sequence editor.""" + self.layout.template_list( + SEQUENCE_UL_shot.bl_idname, + "", + sed, + "sequences", + context.window_manager.sequence_settings, + "shot_active_index", + type="DEFAULT", + rows=4, + ) + + # Camera Sub Panels + def draw_shot_strip(self, context, strip): + """Draw shot strip details.""" + shot_box = self.layout.box() + row = shot_box.row(align=True) + row.label(text=strip.name, icon="SEQUENCE") + + row.operator("sequence.child_scene_setup_create", text="", icon="DUPLICATE") + # Scene selection. + row.menu( + SEQUENCE_MT_active_shot_scene_select.bl_idname, + text=strip.scene.name, + icon="SCENE_DATA", + ) + + # Camera selection. + col = shot_box.column() + active_cam = context.scene.camera + strip_cam = strip.scene_camera + if not active_cam and not strip_cam: + text = "None" + icon = "NONE" + else: + text = ( + f"Active ({context.scene.camera.name})" + if active_cam != strip_cam + else strip.scene_camera.name + ) + icon = "OUTLINER_OB_CAMERA" if active_cam == strip_cam else "CAMERA_DATA" + + row = col.row(align=True, heading="Camera") + row.menu( + SEQUENCE_MT_active_shot_camera_select.bl_idname, + text=text, + icon=icon, + ) + + if not active_cam: + return + + # Helper button to set strip camera to scene's active camera. + if active_cam != strip_cam: + props = row.operator( + "sequence.active_shot_camera_set", + icon="OUTLINER_OB_CAMERA", + text="", + ) + props.camera = context.scene.camera.name + row = col.row() + row.operator("scene.camera_select", icon="RESTRICT_SELECT_OFF", text="Select") + + +class PROPERTIES_PT_obj_users_scene_check(bpy.types.Panel): + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + bl_label = "Sequencer Usage" + + def draw(self, context): + self.layout.operator("sequence.check_obj_users_scene", icon="TEXT") + + +classes = ( + DOPESHEET_PT_Sequence, + SEQUENCE_MT_active_shot_camera_select, + SEQUENCE_MT_active_shot_scene_select, + SEQUENCE_UL_shot, + VIEW3D_PT_sequence, + PROPERTIES_PT_obj_users_scene_check, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from spa_sequencer.shared_folders import ( + core, + ops, + ui, +) + + +def register(): + core.register() + ops.register() + ui.register() + + +def unregister(): + core.unregister() + ops.unregister() + ui.unregister() @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from typing import Union + +import bpy + + +# Name of the collection holding the shared folders collections +SHARED_FOLDERS_ROOT_NAME = ".SHARED_FOLDERS" + + +def get_shared_folders_root_collection() -> Union[bpy.types.Collection, None]: + """Get the root collection holding all the collection used as shared folders.""" + return bpy.data.collections.get(SHARED_FOLDERS_ROOT_NAME, None) + + +def get_or_create_shared_folders_root_collection() -> bpy.types.Collection: + """Get or create the root collection that holds shared folders. + + :return: The shared_folders root collection. + """ + if not (root_collection := get_shared_folders_root_collection()): + root_collection = bpy.data.collections.new(name=SHARED_FOLDERS_ROOT_NAME) + # Use fake user to avoid this datablock to be removed even if unused + root_collection.use_fake_user = True + return root_collection + + +def get_shared_folder_by_name(name: str) -> bpy.types.Collection: + """Get a shared folder by `name`. + + :return: The shared folder if found. + """ + if not (root_collection := get_shared_folders_root_collection()): + raise RuntimeError("No shared folders in this file") + + if not (col := root_collection.children.get(name, None)): + raise ValueError(f"No shared folder named '{name}'") + + return col + + +def make_shared_folder_from_collection(collection: bpy.types.Collection): + """Turn `collection` into a shared folder. + + :param collection: The collection. + """ + # Discard scenes master collections + if collection not in bpy.data.collections.values(): + raise ValueError("Scene collections cannot be used as shared folder") + + root = get_or_create_shared_folders_root_collection() + + if collection in root.children.values(): + raise ValueError(f"Collection '{collection.name}' is already a shared folder") + + root.children.link(collection) + + +def create_shared_folder(name: str) -> bpy.types.Collection: + """Create and return a shared folder named `name`. + + :param name: The name of the shared folder. + """ + if name in bpy.data.collections: + raise ValueError(f"Collection '{name}' already exists") + col = bpy.data.collections.new(name) + make_shared_folder_from_collection(col) + return col + + +def is_shared_folder(collection: bpy.types.Collection) -> bool: + """Return whether `collection` is a shared folder. + + :param collection: The collection. + :return: Whether the collection is a shared folder. + """ + if not (root_collection := get_shared_folders_root_collection()): + return False + return collection.name in root_collection.children + + +def ensure_shared_folder(collection: bpy.types.Collection): + """Raises ValueError if collection is not a shared folder. + + :param collection: The collection. + """ + if not is_shared_folder(collection): + raise ValueError(f"The collection {collection.name} is not a shared folder") + + +def delete_shared_folder(collection: bpy.types.Collection): + """Delete the shared folder `collection`. + + :param collection: The shared folder to delete. + """ + ensure_shared_folder(collection) + bpy.data.collections.remove(collection) + + +def link_shared_folder(collection: bpy.types.Collection, scenes: list[bpy.types.Scene]): + """ + Link the shared folder `collection` in each scene's collection in `scenes`. + Does nothing for scenes in which `collection` is already linked. + + :param collection: The shared folder. + :param scenes: The scenes to link the shared folder in. + :return: The list of scenes in which the folder was acually linked. + """ + ensure_shared_folder(collection) + linked_scenes = [] + for scene in scenes: + if collection.name not in scene.collection.children: + scene.collection.children.link(collection) + linked_scenes.append(scene) + return linked_scenes + + +def unlink_shared_folder( + collection: bpy.types.Collection, scenes: list[bpy.types.Scene] +) -> list[bpy.types.Scene]: + """ + Unlink the shared folder `collection` from each scene in `scenes`. + Does nothing for scenes in which `collection` is not linked. + + :param collection: The shared folder. + :param scenes: The scenes to unlink the shared folder from. + :return: The list of scenes from which the folder was acually unlinked. + """ + ensure_shared_folder(collection) + unlinked_scenes = [] + for scene in scenes: + if collection.name in scene.collection.children: + scene.collection.children.unlink(collection) + unlinked_scenes.append(scene) + return unlinked_scenes + + +def create_and_link_shared_folder( + name: str, scenes: list[bpy.types.Scene] +) -> tuple[bpy.types.Collection, list[bpy.types.Scene]]: + """Create new shared folder named `name` and link it in `scenes`. + + :param name: The shared folder name. + :param scenes: The scenes to link the shared folder in. + :return: The created shared folder collection and the list of scenes in which it was linked. + """ + collection = create_shared_folder(name) + linked_scenes = link_shared_folder(collection, scenes) + return collection, linked_scenes + + +def get_scene_users(collection: bpy.types.Collection) -> list[bpy.types.Scene]: + """Get all scene that uses `collection`. + + :param collection: The shared folder. + """ + return [ + user + for user in bpy.data.user_map(subset=[collection])[collection] + if isinstance(user, bpy.types.Scene) + ] + + +def get_scene_sequence_users( + collection: bpy.types.Collection, sed: bpy.types.SequenceEditor +) -> list[bpy.types.SceneSequence]: + """Get all scene sequence strips with scenes that uses `collection`. + + :param collection: The shared folder. + :param sed: The sequence editor containing the scene sequences. + """ + scene_users = get_scene_users(collection) + return [ + s + for s in sed.sequences + if isinstance(s, bpy.types.SceneSequence) and s.scene in scene_users + ] + + +def get_active_shared_folder( + context: bpy.types.Context, +) -> Union[bpy.types.Collection, None]: + """ + Get the shared folder for current `WindowManager.active_shared_folder_index` value. + + :return: The shared folder collection or None if not applicable. + """ + if not (root := get_shared_folders_root_collection()): + return None + idx = context.window_manager.active_shared_folder_index + if idx < 0 or idx >= len(root.children): + return None + return root.children[idx] + + +def set_active_shared_folder( + context: bpy.types.Context, collection: bpy.types.Collection +): + """Make `collection` the active shared folder.""" + context.window_manager.active_shared_folder_index = ( + get_shared_folders_root_collection().children.find(collection.name) + ) + + +def find_layer_collection( + view_layer: bpy.types.ViewLayer, collection: bpy.types.Collection +) -> Union[bpy.types.LayerCollection, None]: + """Find the first LayerCollection wrapping `collection` in `view_layer`. + + :param view_layer: The view layer to consider + :param collection: The collection to look for + :return: The layer collection if found, None otherwise + """ + return next( + ( + lc + for lc in view_layer.layer_collection.children + if lc.collection == collection + ), + None, + ) + + +def on_active_shared_folder_update(self, context: bpy.types.Context): + """ + Make shared folder collection active in `context`'s scene if applicable. + + :param context: The context. + """ + col = get_active_shared_folder(context) + if not col or not (layer_col := find_layer_collection(context.view_layer, col)): + return + context.view_layer.active_layer_collection = layer_col + + +def register(): + # Active shared folder index property + bpy.types.WindowManager.active_shared_folder_index = bpy.props.IntProperty( + options=set(), + update=on_active_shared_folder_update, + ) + + +def unregister(): + del bpy.types.WindowManager.active_shared_folder_index @@ -0,0 +1,232 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.shared_folders import core +from spa_sequencer.utils import register_classes, unregister_classes + + +class COLLECTION_OT_shared_folder_from_collection(bpy.types.Operator): + bl_idname = "collection.shared_folder_from_collection" + bl_label = "Make Shared Folder" + bl_description = "Create a shared folder from an existing collection" + bl_options = {"UNDO"} + + @classmethod + def poll(self, context: bpy.types.Context): + return context.collection and not core.is_shared_folder(context.collection) + + def execute(self, context: bpy.types.Context): + core.make_shared_folder_from_collection(context.collection) + return {"FINISHED"} + + +def get_scenes_from_selected_sequences( + sed: bpy.types.SequenceEditor, +) -> list[bpy.types.Scene]: + """Return scenes from the list of selected sequences in `sed`.""" + return [ + s.scene + for s in sed.sequences + if isinstance(s, bpy.types.SceneSequence) and s.select and s.scene + ] + + +def get_active_or_selected_scenes(context: bpy.types.Context) -> list[bpy.types.Scene]: + """ + Get active or selected scenes based on `context`. + If context's area is a sequence editor, returns all the scenes from selected scene strips. + Otherwise, return a list containing only context's active scene. + + :param context: The context. + :return: The list of (active or selected) scenes based on `context`. + """ + if context.area.type == "SEQUENCE_EDITOR" and context.scene.sequence_editor: + return get_scenes_from_selected_sequences(context.scene.sequence_editor) + else: + return [context.scene] + + +def build_collection_colors_enum(): + """Build the enum list of available collection colors.""" + values = [("NONE", "", "", "OUTLINER_COLLECTION", 0)] + for i in range(1, 9): + values.append((f"COLOR_{i:02}", "", "", f"COLLECTION_COLOR_{i:02}", i)) + return values + + +class COLLECTION_OT_shared_folder_new(bpy.types.Operator): + bl_idname = "collection.shared_folder_new" + bl_label = "New Shared Folder" + bl_description = "Create a new shared folder" + bl_options = {"UNDO"} + # Setup "name" as primary property (gets UI focus) + bl_property = "name" + + name: bpy.props.StringProperty( + name="Name", description="Name of the Shared Folder", options={"SKIP_SAVE"} + ) + + # Build the color enum items list once + COLLECTION_COLOR_ENUM = build_collection_colors_enum() + + color: bpy.props.EnumProperty( + name="Color", + description="Shared Folder color", + items=COLLECTION_COLOR_ENUM, + ) + + sequencer_link_mode: bpy.props.EnumProperty( + name="Link in Shots", + items=( + ("NONE", "None", "None"), + ("CURRENT", "Current", "Current Shot"), + ("SELECTED", "Selected", "Selected Shots"), + ), + default="SELECTED", + ) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + return context.window_manager.invoke_props_dialog(self, width=340) + + def draw(self, context: bpy.types.Context): + self.layout.use_property_split = True + self.layout.prop(self, "name") + self.layout.prop(self, "color", expand=True, text="Color") + if context.area.type == "SEQUENCE_EDITOR": + self.layout.prop(self, "sequencer_link_mode") + + def execute(self, context: bpy.types.Context): + if not self.name: + self.report({"ERROR"}, "Shared folder name is empty") + return {"CANCELLED"} + + # Get scenes based on context + scenes = get_active_or_selected_scenes(context) + if context.area.type == "SEQUENCE_EDITOR": + if self.sequencer_link_mode == "CURRENT": + # Easy way of getting the currently active shot in a VSE override setup + scenes = [context.window.scene] + elif self.sequencer_link_mode == "NONE": + scenes = [] + + col, linked = core.create_and_link_shared_folder(self.name, scenes) + col.color_tag = self.color + core.set_active_shared_folder(context, col) + + self.report( + {"INFO"}, + f"Shared folder '{self.name}' created and linked in {len(linked)} shot(s)", + ) + + if context.area.type == "SEQUENCE_EDITOR": + context.area.tag_redraw() + + return {"FINISHED"} + + +class COLLECTION_OT_shared_folder_shots_link(bpy.types.Operator): + bl_idname = "collection.shared_folder_shots_link" + bl_label = "Link Shared Folder in Shots" + bl_description = "Link a shared folder in active or selected shots" + bl_options = {"UNDO"} + + shared_folder_name: bpy.props.StringProperty(name="Shared Folder") + + def execute(self, context: bpy.types.Context): + scenes = get_active_or_selected_scenes(context) + collection = bpy.data.collections[self.shared_folder_name] + linked = core.link_shared_folder(collection, scenes) + core.set_active_shared_folder(context, collection) + + self.report( + {"INFO"}, + f"Linked shared folder '{self.shared_folder_name}' in {len(linked)} shot(s)", + ) + + if not linked: + return {"CANCELLED"} + + return {"FINISHED"} + + +class COLLECTION_OT_shared_folder_shots_unlink(bpy.types.Operator): + bl_idname = "collection.shared_folder_shots_unlink" + bl_label = "Unlink Shared Folder from Shots" + bl_description = "Unlink a shared folder from active or selected shots" + bl_options = {"UNDO"} + + shared_folder_name: bpy.props.StringProperty(name="Shared Folder") + + def execute(self, context: bpy.types.Context): + scenes = get_active_or_selected_scenes(context) + collection = bpy.data.collections[self.shared_folder_name] + unlinked = core.unlink_shared_folder(collection, scenes) + core.set_active_shared_folder(context, collection) + + self.report( + {"INFO"}, + f"Unlinked shared folder '{self.shared_folder_name}' from {len(unlinked)} shot(s)", + ) + + if not unlinked: + return {"CANCELLED"} + + return {"FINISHED"} + + +class COLLECTION_OT_shared_folder_shots_select(bpy.types.Operator): + bl_idname = "collection.shared_folder_shots_select" + bl_label = "Select Shots by Shared Folder" + bl_description = "Select shots that uses a shared folder" + bl_options = {"UNDO"} + + shared_folder_name: bpy.props.StringProperty(name="Shared Folder") + + @classmethod + def poll(self, context: bpy.types.Context): + return context.scene.sequence_editor is not None + + def execute(self, context: bpy.types.Context): + seqs = core.get_scene_sequence_users( + bpy.data.collections[self.shared_folder_name], context.scene.sequence_editor + ) + bpy.ops.sequencer.select_all(action="DESELECT") + for seq in seqs: + seq.select = True + + return {"FINISHED"} + + +class COLLECTION_OT_shared_folder_delete(bpy.types.Operator): + bl_idname = "collection.shared_folder_delete" + bl_label = "Delete Shared Folder" + bl_description = "Delete a shared folder" + bl_options = {"UNDO"} + + shared_folder_name: bpy.props.StringProperty(name="Shared Folder") + + def execute(self, context: bpy.types.Context): + core.delete_shared_folder(bpy.data.collections[self.shared_folder_name]) + self.report({"INFO"}, f"Deleted shared folder '{self.shared_folder_name}'") + + return {"FINISHED"} + + +classes = ( + COLLECTION_OT_shared_folder_from_collection, + COLLECTION_OT_shared_folder_new, + COLLECTION_OT_shared_folder_shots_link, + COLLECTION_OT_shared_folder_shots_unlink, + COLLECTION_OT_shared_folder_shots_select, + COLLECTION_OT_shared_folder_delete, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.shared_folders.core import ( + get_active_shared_folder, + get_scene_users, + get_shared_folders_root_collection, +) +from spa_sequencer.utils import register_classes, unregister_classes + + +class COLLECTION_UL_shared_folders(bpy.types.UIList): + """Display shared folder items.""" + + bl_idname = "COLLECTION_UL_shared_folders" + + def draw_item( + self, context, layout, data, item, icon, active_data, active_propname + ): + users = get_scene_users(item) + row = layout.row() + if self.layout_type in {"DEFAULT", "COMPACT"}: + row.prop(item, "name", icon_value=icon, text="", emboss=False) + row.alignment = "EXPAND" + row = layout.row() + row.alignment = "RIGHT" + # Shot context: expose link/unlink operators + if context.area.type == "VIEW_3D": + # Unlinked + if context.scene not in users: + kwargs = { + "operator": "collection.shared_folder_shots_link", + "icon": "UNLINKED", + } + # Linked + else: + kwargs = { + "operator": "collection.shared_folder_shots_unlink", + "icon": "LINKED", + "depress": True, + } + + props = row.operator(**kwargs, text="", emboss=False) + props.shared_folder_name = item.name + # Sequence editor context + else: + # Display the number of users + row.label(text=f"{len(users)} ") + elif self.layout_type in {"GRID"}: + layout.prop(item, "name", text="", emboss=False, icon_value=icon) + + +class BASE_PT_SharedFoldersPanel(bpy.types.Panel): + bl_label = "Shared Folders" + bl_category = "SPA.Sequencer" + bl_region_type = "UI" + bl_options = {"DEFAULT_CLOSED"} + + def draw(self, context: bpy.types.Context): + self.layout.use_property_split = True + self.layout.use_property_decorate = False + + root_col = get_shared_folders_root_collection() + if not root_col or len(root_col.children) == 0: + self.layout.operator("collection.shared_folder_new", icon="ADD") + return + + if context.window_manager.active_shared_folder_index >= len(root_col.children): + context.window_manager.active_shared_folder_index = ( + len(root_col.children) - 1 + ) + + shared_folder = get_active_shared_folder(context) + + row = self.layout.row() + col = row.column() + + col.template_list( + COLLECTION_UL_shared_folders.bl_idname, + "", + root_col, + "children", + context.window_manager, + "active_shared_folder_index", + type="DEFAULT", + rows=3, + ) + + col = row.column(align=True) + + props = col.operator("collection.shared_folder_new", icon="ADD", text="") + + props = col.operator("collection.shared_folder_delete", icon="REMOVE", text="") + props.shared_folder_name = shared_folder.name + + if context.area.type == "VIEW_3D": + return + + col.separator() + + props = col.operator( + "collection.shared_folder_shots_select", icon="RESTRICT_SELECT_OFF", text="" + ) + props.shared_folder_name = shared_folder.name + + col.separator() + + props = col.operator( + "collection.shared_folder_shots_link", icon="LINKED", text="" + ) + props.shared_folder_name = shared_folder.name + + props = col.operator( + "collection.shared_folder_shots_unlink", icon="UNLINKED", text="" + ) + props.shared_folder_name = shared_folder.name + + +class VIEW3D_PT_SharedFoldersPanel(BASE_PT_SharedFoldersPanel): + """ + Panel that displays and gives control over the list of shared folders within the + active shot. + """ + + bl_space_type = "VIEW_3D" + + +class SEQUENCER_PT_SharedFoldersPanel(BASE_PT_SharedFoldersPanel): + """ + Panel that displays and gives control over the list of shared folders within the + sequence editor. + """ + + bl_space_type = "SEQUENCE_EDITOR" + + +def draw_shared_folder_menu(self, context: bpy.types.Context): + """Shared folder entries in outliner context menu for collections.""" + self.layout.separator() + self.layout.operator( + "collection.shared_folder_from_collection", icon="OUTLINER_COLLECTION" + ) + + +classes = ( + COLLECTION_UL_shared_folders, + SEQUENCER_PT_SharedFoldersPanel, + VIEW3D_PT_SharedFoldersPanel, +) + + +def register(): + register_classes(classes) + + bpy.types.OUTLINER_MT_collection.append(draw_shared_folder_menu) + + +def unregister(): + unregister_classes(classes) + bpy.types.OUTLINER_MT_collection.remove(draw_shared_folder_menu) @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from spa_sequencer.shot import ( + core, + naming, + ops, + ui, +) + + +def register(): + core.register() + naming.register() + ops.register() + ui.register() + + +def unregister(): + core.unregister() + naming.unregister() + ops.unregister() + ui.unregister() @@ -0,0 +1,665 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from typing import Callable + +import bpy + +from spa_sequencer.preferences import get_addon_prefs +from spa_sequencer.sync.core import ( + get_sync_settings, + remap_frame_value, +) + +# Data structure to map source-to-duplicated datablock +DuplicationManifest = dict[bpy.types.ID, bpy.types.ID] + + +def duplicate_object( + obj: bpy.types.Object, + parent_col: bpy.types.Collection, + manifest: DuplicationManifest, +) -> bpy.types.Object: + """Duplicate `obj` and link it to `parent_col`. + + :param obj: The object to duplicate + :param parent_col: The collection to link the duplicated object into + :param manifest: Source to duplicated datablocks + :return: The newly created object + """ + + def duplicate_datablock(datablock: bpy.types.ID): + """ + Duplicate `datablock`, register in manifest and return the copy if `datablock` + is valid, None otherwise. + """ + if not datablock: + return None + + db = datablock.copy() + manifest[datablock] = db + return db + + def duplicate_anim_data(datablock: bpy.types.ID): + """ + Duplicate `datablock`'s anim data (action), register in manifest and return the + copy if `datablock` is valid, None otherwise. + """ + if datablock and datablock.animation_data and datablock.animation_data.action: + action = duplicate_datablock(datablock.animation_data.action) + datablock.animation_data.action = action + return action + return None + + # Copy the object and duplicate attached datablocks + new_obj = duplicate_datablock(obj) + new_obj.data = duplicate_datablock(new_obj.data) + + duplicate_anim_data(new_obj) + duplicate_anim_data(new_obj.data) + + parent_col.objects.link(new_obj) + + return new_obj + + +def duplicate_objects( + objs: list[bpy.types.Object], + parent_col: bpy.types.Collection, + manifest: DuplicationManifest, +): + """Duplicate all objects in `objs` and link them to `parent_col`. + + :param obj: The objects to duplicate + :param parent_col: The collection to link the duplicated object into + :param manifest: Source to duplicated datablocks + :return: The newly created objects + """ + new_objs = [] + for obj in objs: + new_obj = duplicate_object(obj, parent_col, manifest) + new_objs.append(new_obj) + return new_objs + + +def duplicate_collection( + col: bpy.types.Collection, + parent_col: bpy.types.Collection, + manifest: DuplicationManifest, +) -> bpy.types.Collection: + """ + Duplicate the collection `col` and link it to `parent_col`. + If `col` has more than 1 user, only link it to `parent_col`. + + :param col: The collection to duplicate + :param parent_col: The collection to link the duplicated collection into + :param manifest: Source to duplicated datablocks + :return: The newly created collection + """ + # Only link collection with multiple users + if col.users > 1: + parent_col.children.link(col) + return col + + # Create new collection + new_col = bpy.data.collections.new(col.name) + manifest[col] = new_col + + # Duplicate source objects into this new collection + duplicate_objects(col.objects, new_col, manifest) + # Duplicate children collection + duplicate_collections(col.children, new_col, manifest) + # Link the new collection to the specified parent + parent_col.children.link(new_col) + + return new_col + + +def duplicate_collections( + cols: list[bpy.types.Collection], + parent_col: bpy.types.Collection, + manifest: DuplicationManifest, +) -> list[bpy.types.Collection]: + """Duplicate the collections `cols` and link them to `parent_col`. + + :param cols: The collections to duplicate + :param parent_col: The collection to link the duplicated collections into + :param manifest: Source to duplicated datablocks + :return: The newly created collections + """ + new_cols = [] + for col in cols: + new_col = duplicate_collection(col, parent_col, manifest) + new_cols.append(new_col) + return new_cols + + +def is_local_single_user(datablock: bpy.types.ID): + """Returns whether `datablock` is a local, single_user datablock.""" + return datablock and datablock.users == 1 and not datablock.library + + +def replace_in_datablock_name( + datablock: bpy.types.ID, + old_substr: str, + new_substr: str, + preprocess: Callable[[str], str] = lambda x: x, +): + """ + Replace occurrences of substring `old_substr` by `new_substr` in `datablock`'s name. + + :param old_substr: The substring to replace + :param new_substr: The new substring to use + :param datablocks: The datablocks to modify + :param preprocess: Pre-process function to apply if datablock contains `old_substr` in its name + """ + # Discard datablocks that are not local single-users ones + if not is_local_single_user(datablock): + return + + if old_substr in datablock.name: + datablock.name = preprocess(datablock.name).replace(old_substr, new_substr) + + +def remap_relations(manifest: DuplicationManifest): + """Remap relations for duplicated datablocks. + + :param manifest: The duplication manifest holding source to duplicated datablocks. + """ + + def _reparent(src_object: bpy.types.Object, new_object: bpy.types.Object) -> bool: + """ + Remap the parent of `new_object` to the duplicate of `src_object`'s parent if any. + + :param src_object: The source object. + :param new_object: The duplicate of `src_object`. + :return: Whether `new_object` has been reparented. + """ + # Find the duplicate of source object's parent, return if not in the manifest. + new_parent = manifest.get(src_object.parent, None) + if not new_parent: + return False + + # Parent the new object to its rightful owner. + new_object.parent = new_parent + # Assigning parent may update parent inverse matrix; restore it afterwards. + new_object.matrix_parent_inverse = src_datablock.matrix_parent_inverse.copy() + return True + + def _get_pointer_properties(datablock: bpy.types.ID) -> list[str]: + """Get the names of pointer properties in `datablock`.""" + return [ + prop.identifier + for prop in datablock.bl_rna.properties + if isinstance(prop, bpy.types.PointerProperty) + ] + + def _remap_pointer_properties(datablocks: list[bpy.types.ID]): + """ + Remap all pointer properties of `datablocks` referencing a duplicated datablock. + """ + for datablock in datablocks: + for prop_name in _get_pointer_properties(datablock): + if (value := getattr(datablock, prop_name)) in manifest: + setattr(datablock, prop_name, manifest[value]) + + def _remap_constraints(new_object: bpy.types.Object): + """Remap `new_object`'s constraints properties.""" + _remap_pointer_properties(new_object.constraints) + + def _remap_modifiers(new_object: bpy.types.Object): + """Remap `new_object`'s modifiers properties.""" + _remap_pointer_properties(new_object.modifiers) + if isinstance(new_object.data, bpy.types.GreasePencil): + _remap_pointer_properties(new_object.grease_pencil_modifiers) + _remap_pointer_properties(new_object.shader_effects) + + def _remap_drivers(new_datablock: bpy.types.ID): + """Remap `new_datablock`'s drivers targets.""" + if not (anim_data := getattr(new_datablock, "animation_data", None)): + return + # Iterate over drivers and remap targets if applicable + for fcurve in anim_data.drivers: + for var in fcurve.driver.variables: + for target in var.targets: + if target.id in manifest: + target.id = manifest[target.id] + + # Remap relationships and references to datablocks in duplicated elements + for src_datablock, new_datablock in manifest.items(): + if isinstance(src_datablock, bpy.types.Object): + _reparent(src_datablock, new_datablock) + _remap_constraints(new_datablock) + _remap_modifiers(new_datablock) + # Remap animation drivers targets + _remap_drivers(new_datablock) + + +def duplicate_scene( + context: bpy.types.Context, + scene: bpy.types.Scene, + name: str, + manifest: DuplicationManifest = None, +) -> bpy.types.Scene: + """Duplicates `scene` as a new scene named `name`. + + :param context: The context + :param scene: The Scene to duplicate + :param name: The name of the new scene + :param manifest: The duplication manifest mapping source-to-duplicated datablocks + :returns: The new created scene + """ + if name in bpy.data.scenes: + raise ValueError(f"Scene '{scene}' already exists") + + # Creating a new empty scene leads to no camera being available. + # This makes all 3D viewports switch to user perspective mode. + # To avoid such changes in the UI and restore them properly, + # save 3D views view_perspective modes before the operation. + region3D_view_mode: dict[bpy.types.RegionView3D, str] = {} + for area in bpy.context.screen.areas: + if area.type == "VIEW_3D": + region = area.spaces.active.region_3d + region3D_view_mode[region] = region.view_perspective + + # Create a new scene based on the source scene's settings + # Note: context override is not enough here - we need to make the scene to duplicate + # the active scene, as the operator uses that internally. + initial_window_scene = context.window.scene + context.window.scene = scene + bpy.ops.scene.new(type="EMPTY") + # This operator makes the newly created scene active + new_scene = context.window.scene + + if manifest is None: + manifest = DuplicationManifest() + + # Add the scene to the manifest + manifest[scene] = new_scene + + # Duplicate scene's root collections and objects + duplicate_collections( + scene.collection.children, new_scene.collection, manifest=manifest + ) + duplicate_objects(scene.collection.objects, new_scene.collection, manifest=manifest) + + # Preprocess: remap relationships between duplicated objects + remap_relations(manifest) + + # Set new scene's properties based on source scene: + # - Camera + new_scene.camera = manifest.get(scene.camera) + # - Active object + new_scene.view_layers[0].objects.active = manifest.get( + scene.view_layers[0].objects.active + ) + # - World (link to the same world object) + new_scene.world = scene.world + + # Rename the duplicated datablocks and the scene itself + # Note: Specify to remove any Blender-automatic-numbering-suffix + # from datablocks that are impacted by the renaming. + for datablock in manifest.values(): + replace_in_datablock_name( + datablock, + scene.name, + name, + preprocess=lambda x: x.rsplit(".", 1)[0], + ) + + new_scene.name = name + + # Restore active window to initial scene + context.window.scene = initial_window_scene + + # Restore 3D views perspective mode + for region3D, view_mode in region3D_view_mode.items(): + region3D.view_perspective = view_mode + + return new_scene + + +def rename_all_datablocks_from_collection( + col: bpy.types.Collection, old_substr: str, new_substr: str +): + """Recursively rename all local single-users datablocks starting from `col`, + by replacing occurences of `old_substr` by `new_substr`. + + :param col: The root collection to start the renaming from + :param old_substr: the substring to replace + :param new_substr: the new substring to use + """ + # Rename the collection itself + replace_in_datablock_name(col, old_substr, new_substr) + + # Rename collection content + for obj in col.objects: + # Rename object, data and any attached action + for datablock in (obj, obj.data): + if not datablock: + continue + replace_in_datablock_name(datablock, old_substr, new_substr) + if datablock.animation_data and datablock.animation_data.action: + replace_in_datablock_name( + datablock.animation_data.action, old_substr, new_substr + ) + + # Recursively rename children collections + for child_col in col.children: + rename_all_datablocks_from_collection(child_col, old_substr, new_substr) + + +def rename_scene(scene: bpy.types.Scene, new_name: str): + """Rename `scene` to `new_name` and replace occurences of `scene`'s original name + by `new_name` in all local single-user datablocks within it. + + :param scene: The scene to rename + :param new_name: The new name of the scene + """ + # Early return if the scene is already name correctly + if scene.name == new_name: + return + # Avoid clashing names if a scene is already named `new_name` + if new_name in bpy.data.scenes: + raise ValueError(f"Scene '{new_name}' already exists") + + # Rename scene content + rename_all_datablocks_from_collection(scene.collection, scene.name, new_name) + # Rename the scene itself + scene.name = scene.name.replace(scene.name, new_name) + + +def is_orphan(datablock: bpy.types.ID) -> bool: + """Return whether `datablock` is orphan, using advanced orphan detection.""" + # In some cases, datablock.users is set to 1 while not being used (update issue?). + # Use 'bpy.data.user_map' to define whether this datablock is actually orphan. + if datablock.users == 1: + user_map = bpy.data.user_map(subset=[datablock])[datablock] + # Exlude direct children from this result, since they are listed in + # the returned set but should not be considered as users here. + advanced_user_count = len( + [db for db in user_map if getattr(db, "parent", None) != datablock] + ) + return advanced_user_count == 0 + # Otherwise, datablock.users can be used safely to detect orphans + return datablock.users == 0 + + +def delete_datablock_if_orphan(datablock: bpy.types.ID) -> int: + """ + Generic function to delete `datablock` if local and orphan, as well as its + attached datablocks (children, data, animation action), recursively. + + :param datablock: The datablock to delete + :returns: The total number of datablocks deleted by this operation + """ + # Do not try to delete linked or non orphan datablocks + if datablock.library or not is_orphan(datablock): + return 0 + + # Get and copy children list if any + children = getattr(datablock, "children", [])[:] + # Get data if any + data = getattr(datablock, "data", None) + # Get action if any + action = getattr(getattr(datablock, "animation_data", None), "action", None) + + # Remove the datablock + bpy.data.batch_remove([datablock]) + del_count = 1 + + # Recursively clean up related datablocks: + # - children (applicable for: objects, collections) + for c in children: + del_count += delete_datablock_if_orphan(c) + # - data (applicable for: objects) + if data: + del_count += delete_datablock_if_orphan(data) + # - action (applicable for: objects, data) + if action: + del_count += delete_datablock_if_orphan(action) + + return del_count + + +def delete_scene(scene: bpy.types.Scene, purge_orphan_datablocks: bool) -> int: + """ + Delete `scene` and optionally delete datablock that were only used in this context. + + :param scene: The scene to delete + :param purge_orphan_datablocks: Whether to delete orphan datablocks after scene deletion + :returns: The number of datablocks deleted by this operation + """ + potentially_orphan_datablocks: list[bpy.types.ID] = [] + + if purge_orphan_datablocks: + # Store top-level datablocks linked to this scene before its deletion. + # We want to delete datablocks in a hierarchical way for user counts to be + # relevant for detecting orphans. + # Collections containing collections and objects, they should be deleted first. + potentially_orphan_datablocks += scene.collection.children[:] + # Then, all objects without parents should be considered. + potentially_orphan_datablocks += [ + obj for obj in scene.collection.all_objects if not obj.parent + ] + + # Delete the scene + bpy.data.scenes.remove(scene) + + del_count = 1 + + if purge_orphan_datablocks: + # Datablocks are deleted from top to bottom recursively, dealing with + # all collections first, and hierarchies of objects afterwards. + for datablock in potentially_orphan_datablocks: + del_count += delete_datablock_if_orphan(datablock) + + return del_count + + +def reload_strip(strip: bpy.types.Sequence): + """Re-evaluate content length and update `strip` display in the sequencer.""" + # For the strip to re-evaluate its internal scene duration, we need + # to call the sequencer.reload operator, which runs on selected strips. + # Adjust sequence editor selection for this to work properly. + scene = strip.id_data + # Store sequence editor selection + selected_strips = [ + (s, s.select_left_handle, s.select_right_handle) + for s in scene.sequence_editor.sequences + if s.select + ] + + with bpy.context.temp_override(scene=scene): + # Deselect everything but our strip + bpy.ops.sequencer.select_all(action="DESELECT") + strip.select = True + # Force re-evaluation of strip scene's internal range and update strip display + bpy.ops.sequencer.reload() + # Restore sequence editor selection + bpy.ops.sequencer.select_all(action="DESELECT") + for strip, left, right in selected_strips: + strip.select = True + strip.select_left_handle = left + strip.select_right_handle = right + + +def adapt_scene_range(strip: bpy.types.SceneSequence): + """Ensure `strip`'s internel range is fully contained in the scene its using.""" + # Update internal scene's end frame if exceeding the original one + new_frame_end = remap_frame_value(strip.frame_final_end - 1, strip) + if new_frame_end <= strip.scene.frame_end: + return + + strip.scene.frame_end = new_frame_end + reload_strip(strip) + + +def adjust_shot_duration( + strip: bpy.types.SceneSequence, + frame_offset: int, + from_frame_start: bool = False, +) -> bool: + """ + Adjust the duration of `strip` and its underlying scene by offsetting either its end + or start frame (`from_frame_start` set to True) by `frame_offset`. + All strips on the same channel after `strip` are shifted accordingly. + + Note that `frame_offset` is automatically clamped to: + - ensure a minimum strip duration of 1 + - from frame start: stay in strip's scene range + + :param strip: The strip to adjust the duration of. + :param frame_offset: The frame offset to apply. + :param from_frame_start: Whether to offset shot's inner start frame rather than its end frame. + :return: Whether the function modified the duration of `strip`. + """ + if not strip.scene: + raise ValueError(f"Invalid shot: no scene set for '{strip.name}'") + + # Ensure the shot lasts at least 1 frame and compute effective offset + new_duration = max(strip.frame_final_duration + frame_offset, 1) + new_frame_offset = new_duration - strip.frame_final_duration + + # If adjusting from strip's frame start, clamp offset to never go beyond internal + # scene's frame start. + if from_frame_start: + new_start_frame = max( + remap_frame_value(strip.frame_final_start, strip) - new_frame_offset, + strip.scene.frame_start, + ) + new_frame_offset = new_start_frame - remap_frame_value( + strip.frame_final_start, strip + ) + + if new_frame_offset == 0: + return False + + # Get sequence editor and scene from strip + sed_scene: bpy.types.Scene = strip.id_data + sed: bpy.types.SequenceEditor = sed_scene.sequence_editor + + # Identify the other strips that must be shifted to adjust to this duration change + impacted_strips = sorted( + ( + s + for s in sed.sequences + if s.frame_final_start > strip.frame_final_start + and s.channel == strip.channel + ), + key=lambda s: s.frame_final_start, + ) + # Shift all impacted strips by offset. + # Note: we adjust order of execution based on offset's sign to avoid + # overlaps at all time and strips automatically changing channels. + + # Frame start: shift content by offset value and change duration while maintaining + # strip's frame final start. + if from_frame_start: + # Positive offset: increase frame start => decrease strip duration + if new_frame_offset > 0: + # 1. Adjust strip values + strip.frame_offset_start += new_frame_offset + strip.frame_start -= new_frame_offset + # 2. Move impacted strips to the left + for s in impacted_strips: + s.frame_start -= new_frame_offset + # Negative offset: decrease frame start => increase strip duration + else: + # 1. Move impacted strips to the right (reversed order) + for s in reversed(impacted_strips): + s.frame_start -= new_frame_offset + # 2. Adjust strip values + strip.frame_start -= new_frame_offset + strip.frame_offset_start += new_frame_offset + + # Frame end: shift scene and strip's final frame by offset. + else: + # Positive offset: increase frame end => increase duration + if new_frame_offset > 0: + # 1. Move impacted strips to the right (reversed order) + for s in reversed(impacted_strips): + s.frame_start += new_frame_offset + # 2. Adjust strip's duration + strip.frame_final_end += new_frame_offset + # Negative offset: decrease frame end => decrease duration + else: + # 1. Adjust strip's duration + strip.frame_final_duration += new_frame_offset + # 2. Move impacted strips to the left + for s in impacted_strips: + s.frame_start += new_frame_offset + + adapt_scene_range(strip) + return True + + +def slip_shot_content( + strip: bpy.types.SceneSequence, frame_offset: int, clamp_start: bool = False +): + """ + Slip `strip` content by `frame_offset`. + A positive offset moves strip's internal range forwards. + + :param strip: The shot strip to consider. + :param frame_offset: The frame offset to apply. + :param clamp_start: Whether to clamp to scene's frame start. + """ + if clamp_start: + # Clamp offset to never go beyond internal scene's frame start. + new_start_frame = max( + remap_frame_value(strip.frame_final_start, strip) + frame_offset, + strip.scene.frame_start, + ) + + new_frame_offset = new_start_frame - remap_frame_value( + strip.frame_final_start, strip + ) + + else: + new_frame_offset = frame_offset + + # Store external values + frame_final_duration = strip.frame_final_duration + channel = strip.channel + # Offset internal values to perform a content slip + strip.frame_offset_start += new_frame_offset + strip.frame_start -= new_frame_offset + strip.frame_offset_end -= new_frame_offset + # Ensure channel and duration are preserved + strip.channel = channel + strip.frame_final_duration = frame_final_duration + adapt_scene_range(strip) + + +def get_valid_shot_scenes() -> list[bpy.types.Scene]: + """Return the list of scenes considered as usable by a shot strip.""" + prefs = get_addon_prefs() + return [ + scene + for scene in bpy.data.scenes + if ( + # Discard template scenes. + not scene.name.startswith(prefs.shot_template_prefix) + # Discard master sync scene. + and scene != get_sync_settings().master_scene + # Discard empty scenes. + and len(scene.collection.all_objects) + ) + ] + + +def get_scene_cameras(scene: bpy.types.Scene) -> list[bpy.types.Object]: + """Return the list of cameras available in `scene`.""" + return sorted( + (obj for obj in scene.objects if obj.type == "CAMERA"), + key=lambda x: x.name, + ) + + +def register(): + pass + + +def unregister(): + pass @@ -0,0 +1,378 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from enum import Enum +import re +import string +from typing import NamedTuple, Optional, Union + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes + + +class ShotPrefix(Enum): + """Internal presets for shot prefixes.""" + + PREVIZ = "PSH" + SHOT = "SH" + + +class ShotNameData(NamedTuple): + """Simple data structure to hold shot name components.""" + + prefix: str + number: int + take: str + + +class ShotNaming: + """ + ShotNaming centralizes the logic for shot naming convention. + """ + + # Shot prefixes. + prefixes: list[str] = [item.value for item in ShotPrefix] + # Shot number digits count + number_digits: int = 4 + # Shot default number + number_default: int = 10 + # Shot number default increment + number_spacing: int = 10 + # Shot takes count + takes_count: int = 6 # [0-26] + # Shot name components separator + separator: str = "" + + # Shot "no-take" value + _take_none: str = "" + + @property + def prefix_default(self): + """Get default prefix (first item in prefixes).""" + return self.prefixes[0] if self.prefixes else "" + + @property + def number_max(self): + """Get maximal shot number.""" + return 10**self.number_digits - 1 + + @property + def take_values(self) -> list[str]: + """Get the list of acceptable take values.""" + return [self._take_none] + list(string.ascii_uppercase[: self.takes_count]) + + def validate(self): + """Ensure the current configuration is valid, raise Value error if not.""" + if self.takes_count < 0 or self.takes_count > 26: + raise ValueError("Invalid take_count value") + if self.number_digits < 1: + raise ValueError("Invalid number_digits value") + if self.number_default < 0 or self.number_default > self.number_max: + raise ValueError("Invalid number_default value") + if self.number_spacing < 1: + raise ValueError("Invalid number_spacing value") + + @property + def take_regex_str(self): + """Regex pattern for take values.""" + return f"[A-{self.take_values[-1]}]?" if self.takes_count > 0 else "" + + @property + def prefix_regex_str(self): + """Regex pattern for prefix values.""" + return "|".join((item for item in self.prefixes)) + + @property + def number_regex_str(self): + """Regex pattern for number values.""" + return rf"\d{{{self.number_digits}}}" + + @staticmethod + def validate_value(pattern: str, value: str): + """Ensure `value` matches `pattern`, raise ValueError otherwise.""" + if not re.fullmatch(pattern, value): + raise ValueError("Value does not match pattern") + + def shot_regex(self): + """Returns the regex defining the shot naming convention.""" + + # Escape string attributes + sep = re.escape(self.separator) + + return ( + rf"^" + rf"(?P<prefix>{self.prefix_regex_str})({sep})" + rf"(?P<number>{self.number_regex_str})({sep})?" + rf"(?P<take>{self.take_regex_str})" + rf"$" + ) + + def build_shot_name( + self, number: int, prefix: Optional[str] = None, take: Optional[str] = None + ) -> str: + """Build a shot name using given shot name data values. + + :param number: The shot number. + :param prefix: The shot prefix, default prefix is used if None. + :param take: The shot take, default take used if None. + :return: The shot name. + """ + if prefix: + self.validate_value(self.prefix_regex_str, prefix) + if take: + self.validate_value(self.take_regex_str, take) + + return ( + f"{prefix or self.prefix_default}{self.separator}" + f"{number:0{self.number_digits}}{self.separator if take else ''}" + f"{take or self._take_none}" + ) + + def default_shot_name(self): + """Get the default shot name based on current configuration.""" + return self.build_shot_name(self.number_default, self.prefix_default) + + def match_name(self, name: str) -> Union[re.Match, None]: + """Match `name` to internal shot naming regexp. + + :param name: The name to consider. + :return: The Match object on success, None otherwise. + """ + return re.fullmatch(self.shot_regex(), name) + + def shot_data_from_name(self, name: str, strict: bool = True) -> ShotNameData: + """Extract shot name data components from `name`. + + :param name: The name to extract data from. + :param strict: Whether the name matching should be strict. + :return: The extracted shot name data. + """ + prefix = self.prefix_default + number = self.number_default + take = self._take_none + + # Try full match with regex + match = self.match_name(name) + if match: + prefix = match.group("prefix") + number = int(match.group("number")) + take = match.group("take") + # If not strict, try to find at least a number to initialize from + elif not strict: + match = re.search(r"\d+", name) + if match and int(match.group()) < self.number_max: + number = int(match.group()) + # Throw otherwise + else: + raise ValueError("Invalid shot name") + return ShotNameData(prefix, number, take) + + def get_next_number(self, number: int) -> int: + """Get next shot number snapped to spacing. + + :param number: The shot number to start from. + :return: The next shot number snapped to spacing. + """ + # Add spacing. + number += self.number_spacing + # Snap to previous spacing value. + return number - (number % self.number_spacing) + + def next_shot_name_from_name( + self, shot_name: str, custom_increment: Optional[int] = None + ) -> str: + """Get the next shot name from `shot_name`.""" + shot_data = self.shot_data_from_name(shot_name) + number = self.shot_data_from_name(shot_name).number + if custom_increment: + number += custom_increment + else: + number = self.get_next_number(number) + if number > self.number_max: + raise ValueError(f"Cannot have more than {self.number_max} shots") + return self.build_shot_name(number, shot_data.prefix, shot_data.take) + + def get_all_shot_scenes(self) -> list[bpy.types.Scene]: + """Get all scenes in the current file matching the shot naming convention. + + :return: The list of scenes. + """ + return [s for s in bpy.data.scenes if self.match_name(s.name)] + + def next_shot_name_from_scenes(self, custom_increment: Optional[int] = None) -> str: + """ + Get the next available shot name (incremented number) based on existing scenes. + + :return: The next available shot name. + """ + shots = sorted([s.name for s in self.get_all_shot_scenes()]) + if not shots: + return self.default_shot_name() + return self.next_shot_name_from_name(shots[-1], custom_increment) + + def get_all_shot_strips( + self, sed: bpy.types.SequenceEditor + ) -> list[bpy.types.SceneSequence]: + """ + Get all scene strips in the given sequence editor matching the shot naming + convention. + + :param sed: The sequence editor. + :return: The list of Scene strips. + """ + return [ + s + for s in sed.sequences + if isinstance(s, bpy.types.SceneSequence) and self.match_name(s.name) + ] + + def next_shot_name_from_sequences( + self, sed: bpy.types.SequenceEditor, custom_increment: Optional[int] = None + ) -> str: + """Get next shot name from the sequences contained in the given sequence editor. + + :param sed: The sequence editor. + :param custom_increment: Optional custom increment. + :return: The next shot name. + """ + shots = sorted([s.name for s in self.get_all_shot_strips(sed)]) + if not shots: + return self.default_shot_name() + return self.next_shot_name_from_name(shots[-1], custom_increment) + + def next_take_name(self, shot_name: str) -> str: + """ + Get the next available take name (incremented take) for `shot_name` based on + existing scenes. + + :param shot_name: The name of the shot. + :return: The next available take name. + """ + if not self.takes_count: + raise RuntimeError("Shot takes are not supported") + + take_values = self.take_values + shot_data = self.shot_data_from_name(shot_name) + new_take = take_values.index(shot_data.take) + 1 + if new_take >= len(take_values): + raise ValueError(f"Cannot have more than {self.takes_count} takes") + return self.build_shot_name( + number=shot_data.number, + prefix=shot_data.prefix, + take=take_values[new_take], + ) + + +# Global ShotNaming instance +shot_naming = ShotNaming() + + +class ShotNamingProperty(bpy.types.PropertyGroup): + def get_prefix_enum(self, ctx): + return [(item.value, item.value, item.name) for item in ShotPrefix] + + prefix: bpy.props.EnumProperty( + name="Prefix", + description="Shot Prefix", + items=get_prefix_enum, + default=0, + options={"SKIP_SAVE"}, + ) + + number: bpy.props.IntProperty( + name="Number", + description="Shot Number", + default=shot_naming.number_default, + min=shot_naming.number_default, + max=shot_naming.number_max, + options={"SKIP_SAVE"}, + ) + + # Adapter enum value for default empty take (enum values cannot be empty strings) + default_take_enum_value: str = "-" + + @classmethod + def take_to_enum(cls, take): + """Convert take name to enum value.""" + return take or cls.default_take_enum_value + + @classmethod + def enum_to_take(cls, enum_value): + """Convert enum value to take name.""" + return enum_value if enum_value != cls.default_take_enum_value else "" + + def get_takes(self, ctx): + """Build the enum take values from shot_naming.""" + return ( + (self.take_to_enum(c), self.take_to_enum(c), c or "Default Take") + for c in shot_naming.take_values + ) + + take: bpy.props.EnumProperty( + name="Take", + description="Shot Take", + items=get_takes, + default=0, + options={"SKIP_SAVE"}, + ) + + def set_use_next_shot_name(self, val): + """Update naming to use next available shot name.""" + sed = bpy.context.scene.sequence_editor + self.init_from_name(shot_naming.next_shot_name_from_sequences(sed)) + + # Helper "function-property" with a set method that makes naming use next shot name + use_next_shot_name: bpy.props.BoolProperty( + name="Use Next Available Shot Name", + description="Update naming to use the next available shot name", + set=set_use_next_shot_name, + options={"HIDDEN", "SKIP_SAVE"}, + ) + + def init_from_name(self, name: str): + """Initialize properties from `name`.""" + shot_data = shot_naming.shot_data_from_name(name, strict=False) + self.prefix = shot_data.prefix + self.number = shot_data.number + self.take = self.take_to_enum(shot_data.take) + + def to_string(self) -> str: + """Return the shot name based on current properties values.""" + return shot_naming.build_shot_name( + self.number, self.prefix, self.enum_to_take(self.take) + ) + + def draw( + self, + layout: bpy.types.UILayout, + text: str = "Shot Name", + show_init_from_next: bool = False, + ): + """Helper function to draw this PropertyGroup.""" + main_row = layout.row(align=True) + main_row.label(text=text) + row = main_row.row(align=True) + row.use_property_split = False + # Disable prefix field by default + prefix = row.row(align=True) + prefix.prop(self, "prefix", text="") + + row.prop(self, "number", text="") + if shot_naming.takes_count > 0: + row.prop(self, "take", text="") + + if show_init_from_next: + main_row.prop(self, "use_next_shot_name", icon="FRAME_NEXT", icon_only=True) + + +classes = (ShotNamingProperty,) + + +def register(): + shot_naming.validate() + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,815 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from typing import Optional + +import bpy + +from spa_sequencer.preferences import get_addon_prefs +from spa_sequencer.shot.core import ( + adjust_shot_duration, + delete_scene, + duplicate_scene, + get_valid_shot_scenes, + rename_scene, + slip_shot_content, +) +from spa_sequencer.shot.naming import shot_naming, ShotNamingProperty +from spa_sequencer.sync.core import ( + get_sync_master_strip, + get_sync_settings, + remap_frame_value, +) +from spa_sequencer.utils import register_classes, unregister_classes + + +def get_last_sequence( + sequences: list[bpy.types.Sequence], +) -> Optional[bpy.types.Sequence]: + """Get the last sequence, i.e. the one with the greatest final frame number.""" + return max(sequences, key=lambda x: x.frame_final_end) if sequences else None + + +def get_last_used_frame( + sequences: list[bpy.types.Sequence], scene: bpy.types.Scene +) -> int: + """ + Get the last used internal frame of `scene` from the given list of `sequences`. + """ + scene_sequences = [ + s + for s in sequences + if isinstance(s, bpy.types.SceneSequence) and s.scene == scene + ] + + if not scene_sequences: + return scene.frame_start - 1 + + return max(remap_frame_value(s.frame_final_end - 1, s) for s in scene_sequences) + + +def get_selected_scene_sequences( + sequences: list[bpy.types.Sequence], +) -> list[bpy.types.SceneSequence]: + """ + :param sequences: The sequences to consider. + :return: The list of selected scene sequence strips. + """ + return [s for s in sequences if isinstance(s, bpy.types.SceneSequence) and s.select] + + +def ensure_sequencer_frame_visible(context: bpy.types.Context, frame: int): + """Ensure `frame` is visible within context's sequencer area. + + :param context: The context - context.area has to be a SequenceEditor. + :param frame: The frame to consider. + """ + if not context.area.type == "SEQUENCE_EDITOR": + return + frame_coord = context.region.view2d.view_to_region(frame, 0, clip=False)[0] + if frame_coord < 0 or frame_coord > context.region.width: + # Temp override current frame value and move view to frame. + frame_old = context.scene.frame_current + context.scene.frame_current = frame + bpy.ops.sequencer.view_frame() + context.scene.frame_current = frame_old + + +class SEQUENCER_OT_shot_new(bpy.types.Operator): + bl_idname = "sequencer.shot_new" + bl_label = "New Shot" + bl_description = "Create a new shot and append it to the timeline" + bl_options = {"REGISTER", "UNDO"} + + template_names = [] + + def get_template_scenes(self, context): + """Get the scenes matching template naming rule defined in preferences.""" + prefs = get_addon_prefs() + prefix = prefs.shot_template_prefix + + shot_scenes = get_valid_shot_scenes() + + def matches_mode(scene): + match self.scene_mode: + case "EXISTING": + # Do not consider templates when using an existing scene. + return scene in shot_scenes + case "TEMPLATE": + # Only show template scenes in this case. + return scene.name.startswith(prefix) + case _: + return True + + # Keep this enum values list alive (see https://developer.blender.org/T97243). + SEQUENCER_OT_shot_new.template_names = [ + (s.name, s.name, "") + for s in bpy.data.scenes + if s != context.scene and matches_mode(s) + ] + + return SEQUENCER_OT_shot_new.template_names + + def update_default_source_scene(self, context): + if self.scene_mode == "EXISTING" and ( + scene := getattr(context.scene.sequence_editor.active_strip, "scene") + ): + self.source_scene = scene.name + + scene_mode: bpy.props.EnumProperty( + name="Scene", + description="Shot creation mode", + items=( + ("EXISTING", "Use Existing", "Use existing scene"), + ("TEMPLATE", "New From Template", "Create a new scene from a template"), + ), + default="EXISTING", + update=update_default_source_scene, + ) + + source_scene: bpy.props.EnumProperty( + name="Source", + description="The scene the new shot will use (either directly or a copy of it)", + items=get_template_scenes, + ) + + name: bpy.props.StringProperty( + name="Shot Name", + default="", + options={"SKIP_SAVE"}, + ) + + naming: bpy.props.PointerProperty( + type=ShotNamingProperty, + name="Shot Naming", + ) + + duration: bpy.props.IntProperty( + name="Duration", + default=24, + min=1, + ) + + channel: bpy.props.IntProperty( + name="Channel", + default=1, + min=1, + max=32, + ) + + def validate_inputs(self, context: bpy.types.Context) -> bool: + if self.name == "": + self.report({"ERROR_INVALID_INPUT"}, "Name cannot be empty") + return False + if self.source_scene not in bpy.data.scenes: + self.report({"ERROR_INVALID_INPUT"}, "Source scene cannot be empty") + return False + return True + + def invoke(self, context: bpy.types.Context, _event): + if not (sed := context.scene.sequence_editor): + self.name = shot_naming.default_shot_name() + else: + self.name = shot_naming.next_shot_name_from_sequences(sed) + self.naming.init_from_name(self.name) + if self.scene_mode == "EXISTING" and ( + scene := getattr(sed.active_strip, "scene", None) + ): + self.source_scene = scene.name + + wm = context.window_manager + return wm.invoke_props_dialog(self) + + def draw(self, context: bpy.types.Context): + self.layout.use_property_split = True + row = self.layout.row(align=True) + self.naming.draw(row, show_init_from_next=True) + self.layout.prop(self, "scene_mode") + self.layout.prop(self, "source_scene") + self.layout.prop(self, "duration") + self.layout.prop(self, "channel") + + def execute(self, context: bpy.types.Context): + # Validate the inputs + if not self.validate_inputs(context): + return {"CANCELLED"} + + source_scene = bpy.data.scenes[self.source_scene] + # Create sequence editor data if needed. + if not context.scene.sequence_editor: + context.scene.sequence_editor_create() + + sequences = context.scene.sequence_editor.sequences + frame_offset_start = 0 + + # Source scene handling. + if self.scene_mode == "EXISTING": + # Use existing scene as is. + shot_scene = source_scene + # Get the last frame used in the source scene to initialize the new strip. + frame_offset_start = get_last_used_frame(sequences, source_scene) + else: + # Duplicate source scene. + shot_scene = duplicate_scene(context, source_scene, self.name) + # Set new scene's frame_end based on duration. + # Note: the end frame must be last 'useful' frame, hence the -1. + shot_scene.frame_end = shot_scene.frame_start + self.duration - 1 + + last_seq = get_last_sequence(sequences) + insert_frame = ( + last_seq.frame_final_end if last_seq else context.scene.frame_start + ) + + bpy.ops.sequencer.select_all(action="DESELECT") + # Create a scene strip from the newly created scene. + new_strip = sequences.new_scene( + self.naming.to_string(), shot_scene, self.channel, insert_frame + ) + new_strip.frame_final_duration = self.duration + slip_shot_content(new_strip, frame_offset_start) + new_strip.scene_camera = new_strip.scene.camera + context.scene.sequence_editor.active_strip = new_strip + + if self.scene_mode == "EXISTING": + shot_scene.camera = source_scene.camera + + context.scene.frame_end = max( + new_strip.frame_final_end - 1, context.scene.frame_end + ) + + # Move current frame to the new strip's start frame. + context.scene.frame_set(insert_frame) + + # Ensure newly created shot is visible. + ensure_sequencer_frame_visible(context, new_strip.frame_final_end) + + self.report({"INFO"}, f"Created new shot '{new_strip.name}'") + + return {"FINISHED"} + + +class SEQUENCER_OT_shot_duplicate(bpy.types.Operator): + bl_idname = "sequencer.shot_duplicate" + bl_label = "Duplicate" + bl_description = "Duplicate selected shot(s) and append them to the timeline" + bl_options = {"UNDO"} + + duplicate_scene: bpy.props.BoolProperty( + name="Duplicate Scene", + description="Whether to also duplicate the underlying Scene", + default=False, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return bool(context.selected_sequences) + + @staticmethod + def duplicate_shot( + context: bpy.types.Context, + strip: bpy.types.SceneSequence, + name: str, + duplicate_scene: bool, + ) -> bpy.types.SceneSequence: + sed = strip.id_data.sequence_editor + if duplicate_scene: + shot_scene = duplicate_scene(context, strip.scene, name) + else: + shot_scene = strip.scene + + # Find the frame where to insert the duplicated strip + insert_frame = get_last_sequence(sed.sequences).frame_final_end + + # Create new strip + new_strip = sed.sequences.new_scene( + name, shot_scene, strip.channel, insert_frame + ) + + new_strip.frame_final_duration = strip.frame_final_duration + + if not duplicate_scene: + new_strip.scene_camera = strip.scene_camera + frame_offset = get_last_used_frame(sed.sequences, shot_scene) + slip_shot_content(new_strip, frame_offset) + else: + new_strip.scene_camera = strip.scene.camera + + return new_strip + + def execute(self, context: bpy.types.Context): + sed = context.scene.sequence_editor + + new_strips = [] + for strip in get_selected_scene_sequences(sed.sequences): + name = shot_naming.next_shot_name_from_sequences(sed) + new_strip = self.duplicate_shot(context, strip, name, self.duplicate_scene) + new_strips.append(new_strip) + + if not new_strips: + return {"CANCELLED"} + + # Move current frame to the new strip's start frame. + context.scene.frame_set(new_strips[0].frame_final_start) + + # Deselect all strips + bpy.ops.sequencer.select_all(action="DESELECT") + + # Set the duplicated strip as the active one + sed.active_strip = new_strips[0] + for strip in new_strips: + strip.select = True + + context.scene.frame_end = max( + new_strips[-1].frame_final_end - 1, context.scene.frame_end + ) + + # Ensure created strips are visible. + ensure_sequencer_frame_visible(context, new_strips[0].frame_final_end) + + self.report({"INFO"}, f"Duplicated {len(new_strips)} shot(s)") + + return {"FINISHED"} + + +class SEQUENCER_OT_shot_delete(bpy.types.Operator): + bl_idname = "sequencer.shot_delete" + bl_label = "Delete Shot" + bl_description = "Delete selected shot(s) and optionally attached datablocks" + bl_options = {"UNDO"} + + delete_scenes: bpy.props.BoolProperty( + name="Delete Scenes", + description="Whether to also delete underlying scene(s)", + default=False, + options=set(), + ) + + delete_orphan_scene_data: bpy.props.BoolProperty( + name="Delete Orphan Data", + description="Delete any datablock orphaned by this process", + default=True, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return bool(context.selected_sequences) + + def invoke(self, context: bpy.types.Context, event): + self.strips = get_selected_scene_sequences( + context.scene.sequence_editor.sequences + ) + if not self.strips: + self.report({"ERROR"}, "No selected shots") + return {"CANCELLED"} + + self.scenes = set([s.scene for s in self.strips if s.scene]) + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + row = self.layout.row() + row.label(text=f"Delete {len(self.strips)} selected shot strip(s)?") + box = self.layout.box() + box.label(text="Advanced", icon="SETTINGS") + row = box.row(align=True) + row.prop(self, "delete_scenes") + if self.delete_scenes: + row.prop(self, "delete_orphan_scene_data") + box.alert = True + box.label(text="Those scene(s) will be deleted:", icon="ERROR") + box.alert = False + subbox = box.box() + col = subbox.column(align=True) + for scene in self.scenes: + row = col.row() + row.label(text=scene.name, icon="DOT") + + def execute(self, context: bpy.types.Context): + deleted_datablocks = 0 + strips = get_selected_scene_sequences(context.scene.sequence_editor.sequences) + + # Store scenes to delete in a set to avoid duplicates + scenes = set() + # Delete the strips + for strip in strips: + if strip.scene and self.delete_scenes: + scenes.add(strip.scene) + context.scene.sequence_editor.sequences.remove(strip) + + # Delete the scenes + for scene in scenes: + deleted_datablocks += delete_scene(scene, self.delete_orphan_scene_data) + + context.area.tag_redraw() + self.report( + {"INFO"}, + f"Deleted {len(strips)} shots and {deleted_datablocks} datablock(s)", + ) + + return {"FINISHED"} + + +class SEQUENCER_OT_shot_timing_adjust(bpy.types.Operator): + bl_idname = "sequencer.shot_timing_adjust" + bl_label = "Adjust Timing" + bl_description = "Adjust the timing of the active shot interactively" + bl_options = {"GRAB_CURSOR_X", "BLOCKING", "UNDO"} + + offset: bpy.props.IntProperty( + name="Offset", + ) + + mode: bpy.props.EnumProperty( + name="Mode", + items=( + ("DURATION", "Duration", "Adjust strip duration"), + ("SLIP", "Slip", "Slip strip content"), + ), + default="DURATION", + options={"SKIP_SAVE"}, + ) + + strip_handle: bpy.props.EnumProperty( + name="Strip Adjustment Handle", + items=( + ("LEFT", "Left", "Left"), + ("RIGHT", "Right", "Right"), + ), + default="RIGHT", + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return cls.get_active_strip(context) is not None + + @staticmethod + def get_active_strip( + context: bpy.types.Context, + ) -> Optional[bpy.types.SceneSequence]: + if context.area.type == "DOPESHEET_EDITOR": + strip = get_sync_master_strip(use_cache=True)[0] + return strip if strip and strip.scene == context.window.scene else None + elif context.scene.sequence_editor and isinstance( + context.scene.sequence_editor.active_strip, bpy.types.SceneSequence + ): + return context.scene.sequence_editor.active_strip + + return None + + def setup(self, context: bpy.types.Context): + self.strip = self.get_active_strip(context) + if not self.strip: + self.report({"ERROR"}, "No current Shot Strip") + return False + return True + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + if not self.setup(context): + return {"CANCELLED"} + + context.window.cursor_modal_set("SCROLL_X") + + self.start_mouse_coords = context.region.view2d.region_to_view( + x=event.mouse_region_x, y=event.mouse_region_y + ) + + if context.area.type == "SEQUENCE_EDITOR": + self.strip_handle = "LEFT" if self.strip.select_left_handle else "RIGHT" + + self.original_strip_duration = self.strip.frame_final_duration + self.original_strip_scene_end = self.strip.scene.frame_end + self.original_strip_offset_start = self.strip.frame_offset_start + self.original_edit_frame_end = get_sync_settings().master_scene.frame_end + + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + + def update_header_text(self, context, event): + text = ( + f"Offset: {self.offset}" + f" | New Shot Duration: {self.strip.frame_final_duration}" + ) + context.area.header_text_set(text) + + def modal(self, context: bpy.types.Context, event: bpy.types.Event): + self.update_header_text(context, event) + # Cancel + if event.type in {"RIGHTMOUSE", "ESC"}: + self.cancel(context) + return {"CANCELLED"} + # Validate + elif (event.type in {"LEFTMOUSE"} and event.value in {"PRESS", "RELEASE"}) or ( + event.type in {"RET", "NUMPAD_ENTER"} and event.value in {"PRESS"} + ): + if self.offset == 0: + self.cancel(context) + return {"CANCELLED"} + self.restore_ui(context) + return {"FINISHED"} + # Update + elif event.type in {"MOUSEMOVE"}: + mouse_coords = context.region.view2d.region_to_view( + x=event.mouse_region_x, y=event.mouse_region_y + ) + offset = int(mouse_coords[0] - self.start_mouse_coords[0]) + if offset != self.offset: + self.offset = offset + self.execute(context) + + return {"RUNNING_MODAL"} + + def execute(self, context: bpy.types.Context): + if not self.options.is_invoke: + if not self.setup(context): + return {"CANCELLED"} + + from_frame_start = self.strip_handle == "LEFT" + select = self.mode == "DURATION" + self.strip.select_left_handle = select and from_frame_start + self.strip.select_right_handle = select and not from_frame_start + # Adjust offset sign to match direction. + # For instance, a positive offset (going to the right in modal): + # - SHRINKS the strip if using left handle (from frame start) + # - EXTENDS the strip otherwise (from frame end) + offset = -self.offset if from_frame_start else self.offset + # Compute current absolute offset from original duration + if self.mode == "SLIP": + delta = self.strip.frame_offset_start - self.original_strip_offset_start + slip_shot_content(self.strip, offset - delta, clamp_start=True) + else: + delta = self.strip.frame_final_duration - self.original_strip_duration + adjust_shot_duration(self.strip, offset - delta, from_frame_start) + + edit_scene = get_sync_settings().master_scene + if from_frame_start or self.mode == "SLIP": + # NOTE: When adjusting from frame start, the current frame does not change. + # Set time to a non meaningful value before re-setting the correct frame + # value, to trigger time-dependent updates (e.g: synchronization). + edit_scene.frame_set(-1) + update_frame = self.strip.frame_final_start + else: + update_frame = self.strip.frame_final_end - 1 + + # Set sequencer's frame to strip's new end frame + edit_scene.frame_set(update_frame) + + # Update both edit and internal scene's end frame if going past original ones + frame_end = self.strip.frame_final_end - 1 + edit_scene.frame_end = max(frame_end, self.original_edit_frame_end) + self.strip.scene.frame_end = max( + remap_frame_value(frame_end, self.strip), + self.original_strip_scene_end, + ) + + return {"FINISHED"} + + def restore_ui(self, context: bpy.types.Context): + context.area.header_text_set(None) + context.window.cursor_modal_restore() + + def cancel(self, context: bpy.types.Context): + if self.offset: + self.offset = 0 + self.execute(context) + # Restore scenes' original end frames + context.scene.frame_end = self.original_edit_frame_end + self.strip.scene.frame_end = self.original_strip_scene_end + self.restore_ui(context) + + +class SEQUENCER_OT_shot_rename(bpy.types.Operator): + bl_idname = "sequencer.shot_rename" + bl_label = "Rename" + bl_description = "Rename the active shot" + bl_options = {"REGISTER", "UNDO"} + + naming: bpy.props.PointerProperty( + type=ShotNamingProperty, + description="Name of the shot using shot naming system", + ) + + rename_scene: bpy.props.BoolProperty( + name="Rename Scene", + description="Whether to also rename strip's scene", + default=False, + ) + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor and isinstance( + cls.active_shot(context), bpy.types.SceneSequence + ) + + @staticmethod + def active_shot(context) -> bpy.types.SceneSequence: + return context.scene.sequence_editor.active_strip + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + self.naming.init_from_name(self.active_shot(context).name) + return context.window_manager.invoke_props_dialog(self) + + @staticmethod + def draw_rename_details( + layout, + src: str, + dst: str, + icon: str, + forbidden_names: Optional[list[str]] = None, + ): + """Helper function to draw renaming details from `src` to `dst`.""" + row = layout.row(align=True) + row.alignment = "RIGHT" + row.label(text=src, icon=icon) + if src != dst: + row.label(text=dst, icon="FORWARD") + # Display warning if dst name is in the forbidden name list. + if dst in forbidden_names: + row.alert = True + row.label(text="Already exists!", icon="ERROR") + row.alert = False + + def draw(self, context: bpy.types.Context): + shot_strip = self.active_shot(context) + + # Display naming widget. + row = self.layout.row() + self.naming.draw(row, show_init_from_next=True) + current_name = self.naming.to_string() + + self.layout.prop(self, "rename_scene") + + # Strip renaming details. + self.draw_rename_details( + self.layout, + shot_strip.name, + current_name, + "SEQUENCE", + context.scene.sequence_editor.sequences, + ) + + # Scene renaming details. + if self.rename_scene: + self.draw_rename_details( + self.layout, + shot_strip.scene.name, + current_name, + "SCENE_DATA", + bpy.data.scenes, + ) + + def execute(self, context: bpy.types.Context): + shot_strip = self.active_shot(context) + new_name = self.naming.to_string() + + # First, evaluate whether all renaming steps (strip/scene) can occur. + do_rename_strip = do_rename_scene = False + # Evaluate shot strip renaming. + if new_name != shot_strip.name: + # Ensure strip name is available. + if new_name in context.scene.sequence_editor.sequences: + self.report({"ERROR"}, f"Shot '{new_name}' already exists") + return {"CANCELLED"} + do_rename_strip = True + # Evaluate scene renaming. + if self.rename_scene and shot_strip.scene.name != new_name: + # Ensure scene name is available. + if new_name in bpy.data.scenes: + self.report({"ERROR"}, f"Scene '{new_name}' already exists") + return {"CANCELLED"} + do_rename_scene = True + + # Perform renaming. + if do_rename_strip: + shot_strip.name = new_name + if do_rename_scene: + rename_scene(shot_strip.scene, new_name) + + # Force UI update to display new names in sequencer. + context.area.tag_redraw() + return {"FINISHED"} + + +class SEQUENCER_OT_shot_chronological_numbering(bpy.types.Operator): + bl_idname = "sequencer.shot_chronological_numbering" + bl_label = "Chronological Numbering" + bl_description = ( + "Rename all the shots in the edit scene to follow a chronological numbering" + ) + bl_options = {"REGISTER", "UNDO"} + + first_shot_name: bpy.props.PointerProperty( + name="First Shot", + type=ShotNamingProperty, + description="Name of the first shot", + ) + + rename_scenes: bpy.props.EnumProperty( + name="Rename Scenes Policy", + items=( + ("NONE", "None", "Do not rename scenes"), + ("MATCHING", "Matching", "Only rename scene if it matches strip's name"), + ("ALL", "All", "Rename all scenes to match strip's name"), + ), + description="Policy for renaming scenes", + default="NONE", + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + return context.scene.sequence_editor is not None + + def draw(self, context): + self.layout.use_property_split = True + self.layout.use_property_decorate = False + self.first_shot_name.draw(self.layout, text="First Shot") + self.layout.separator() + self.layout.prop(self, "rename_scenes", text="Rename Scenes") + + def invoke(self, context: bpy.types.Context, event): + return context.window_manager.invoke_props_dialog(self, width=350) + + def execute(self, context: bpy.types.Context): + scene_strips = [ + strip + for strip in context.scene.sequence_editor.sequences + if isinstance(strip, bpy.types.SceneSequence) + ] + + if not scene_strips: + return {"CANCELLED"} + + tmp_suffix = ".tmp.rename" + current_name = "" + scenes_to_rename = set() + items_to_rename: dict[bpy.types.SceneSequence, tuple[str, bool]] = dict() + + # Go through the shots chronologically (sorted by the start frame) + sorted_scene_strips = sorted(scene_strips, key=lambda x: x.frame_final_start) + + # 1st pass: list items (strips/scenes) to rename. + for strip in sorted_scene_strips: + if not current_name: + current_name = self.first_shot_name.to_string() + else: + current_name = shot_naming.next_shot_name_from_name(current_name) + + # Evaluate if scene has to be renamed. + do_rename_scene = False + if self.rename_scenes == "ALL" or ( + self.rename_scenes == "MATCHING" and strip.name == strip.scene.name + ): + # Store scenes to rename in a set. If multiple strips + # uses the same scene, only consider the first renaming. + if strip.scene not in scenes_to_rename: + scenes_to_rename.add(strip.scene) + do_rename_scene = True + # Store renaming details for this strip. + items_to_rename[strip] = (current_name, do_rename_scene) + + # 2nd pass: ensure new scene names won't conflict with existing ones. + for strip, (new_name, do_rename_scene) in items_to_rename.items(): + if do_rename_scene and (scene := bpy.data.scenes.get(new_name, None)): + # Check that the name does not clash with another scene that won't get + # renamed. + if scene not in scenes_to_rename: + # Stop the execution before renaming anything to avoid any partial + # renaming. + self.report( + {"ERROR"}, f"Name conflict: Scene {new_name} already exists" + ) + return {"CANCELLED"} + + # 3rd pass: add temp suffix to avoid clashes between items to rename. + for strip, (_, do_rename_scene) in items_to_rename.items(): + strip.name += tmp_suffix + if do_rename_scene: + rename_scene(strip.scene, strip.scene.name + tmp_suffix) + + # 4th pass: use final name. + for strip, (new_name, do_rename_scene) in items_to_rename.items(): + strip.name = new_name + if do_rename_scene: + rename_scene(strip.scene, new_name) + + # NOTE: for sequencer override, force update area display. + context.area.tag_redraw() + return {"FINISHED"} + + +classes = ( + SEQUENCER_OT_shot_new, + SEQUENCER_OT_shot_duplicate, + SEQUENCER_OT_shot_delete, + SEQUENCER_OT_shot_timing_adjust, + SEQUENCER_OT_shot_rename, + SEQUENCER_OT_shot_chronological_numbering, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes + + +class SEQUENCER_MT_shot_clean_up(bpy.types.Menu): + """Shot clean-up menu""" + + bl_idname = "SEQUENCER_MT_shot_clean_up" + bl_label = "Clean Up" + + def draw(self, context): + layout = self.layout + + layout.operator("sequencer.shot_chronological_numbering") + + +class SEQUENCER_MT_shot(bpy.types.Menu): + """Shot operators menu""" + + bl_idname = "SEQUENCER_MT_shot" + bl_label = "Shot" + + def draw(self, context): + layout = self.layout + + layout.operator("sequencer.shot_new", text="New...") + layout.operator("sequencer.shot_duplicate") + layout.operator("sequencer.shot_delete", text="Delete...") + layout.separator() + layout.operator("sequencer.shot_rename", text="Rename...") + layout.operator("sequencer.shot_timing_adjust") + layout.separator() + layout.menu("SEQUENCER_MT_shot_clean_up") + + +def draw_MT_shot(self, context): + layout = self.layout + layout.menu(SEQUENCER_MT_shot.bl_idname) + + +classes = ( + SEQUENCER_MT_shot, + SEQUENCER_MT_shot_clean_up, +) + + +def register(): + register_classes(classes) + + # Add SEQUENCER_MT_shot to the sequencer editor menus + bpy.types.SEQUENCER_MT_editor_menus.append(draw_MT_shot) + + +def unregister(): + unregister_classes(classes) + + # Remove SEQUENCER_MT_shot from the sequencer editor menus + bpy.types.SEQUENCER_MT_editor_menus.remove(draw_MT_shot) @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +""" +Timeline Synchronization package. +""" + +from spa_sequencer.sync import ( + core, + ops, + ui, +) + + +def register(): + core.register() + ops.register() + ui.register() + + +def unregister(): + core.unregister() + ops.unregister() + ui.unregister() @@ -0,0 +1,670 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +from contextlib import contextmanager +from typing import Any, Callable, Optional, Union, Type + +import bpy + +from spa_sequencer.utils import register_classes, unregister_classes + + +SequenceType = Type[bpy.types.Sequence] + + +class TimelineSyncSettings(bpy.types.PropertyGroup): + """Timeline Synchronization Settings.""" + + enabled: bpy.props.BoolProperty( + name="Enabled", + description="Status of Timeline Synchronization system", + default=False, + ) + + master_scene: bpy.props.PointerProperty( + type=bpy.types.Scene, + name="Master Scene", + description="The Scene that drives the Timeline Synchronization", + ) + + bidirectional: bpy.props.BoolProperty( + name="Bidirectional", + description=( + "Whether changing the active Scene's time should update " + "the Master Scene's current frame" + ), + default=True, + ) + + sync_all_windows: bpy.props.BoolProperty( + name="Synchronize all Windows", + description="Whether the Timeline Synchronization impacts all Main Windows", + default=False, + ) + + keep_gpencil_tool_settings: bpy.props.BoolProperty( + name="Keep Grease Pencil Settings", + description="Keep active Grease Pencil tool settings while navigating Shots", + default=True, + ) + + def use_preview_range_update_callback(self, context): + # Update master strip preview range when the option is changed + if ( + strip := get_sync_master_strip(use_cache=True)[0] + ) and self.use_preview_range: + update_preview_range(strip) + + use_preview_range: bpy.props.BoolProperty( + name="Use Preview Range", + description=( + "Update the preview range of current strip's scene to match the useful " + "range of this strip" + ), + default=True, + update=use_preview_range_update_callback, + ) + + # Cached values from last update + # See sync_system_update function for details + + last_master_frame: bpy.props.IntProperty( + description="Last frame value that triggered an update in master Scene", + default=-1, + options={"HIDDEN"}, + ) + + last_master_strip: bpy.props.StringProperty( + description="Name of the Scene Strip used during the last Synchronization update", + default="", + options={"HIDDEN"}, + ) + + last_master_strip_idx: bpy.props.IntProperty( + description="Index of the Scene Strip used during the last Synchronization update.", + default=-1, + options={"HIDDEN"}, + ) + + last_strip_scene_frame: bpy.props.IntProperty( + description="Last frame value that triggered an update in master strip's Scene", + default=-1, + options={"HIDDEN"}, + ) + + last_strip_scene_frame_out_of_range: bpy.props.BoolProperty( + description="Whether frame value in master strip's Scene is out of strip range", + default=True, + options={"HIDDEN"}, + ) + + last_gp_mode: bpy.props.StringProperty( + description="Last grease pencil interaction mode", + default="", + options={"HIDDEN"}, + ) + + active_follows_playhead: bpy.props.BoolProperty( + name="Active Follows Playhead", + description=("Update the active strip while scrubbing the sequencer"), + default=False, + update=use_preview_range_update_callback, + ) + + +def get_sync_settings() -> TimelineSyncSettings: + """Return the TimelineSyncSettings instance.""" + return bpy.context.window_manager.timeline_sync_settings + + +# Main scene frame set function that will use the optimized or fallback to default +# implementation. +scene_frame_set: Optional[ + Callable[[bpy.types.Context, bpy.types.Scene, int], None] +] = None + + +def _scene_frame_set_default( + context: bpy.types.Context, scene: bpy.types.Scene, frame: int +): + """Default implementation of scene.frame_set.""" + scene.frame_set(frame) + + +def _scene_frame_set_optimized( + context: bpy.types.Context, scene: bpy.types.Scene, frame: int +): + """Set `scene` frame with improved performance when playback is active. + + :param context: The active context. + :param scene: The scene to set current frame on. + :param frame: The frame value. + """ + # This relies on a custom extension of the `frame_set` API. + try: + # Performance: only trigger notifiers when playback is not active + scene.frame_set(frame, notify=not context.screen.is_animation_playing) + except TypeError: + # If notify is not available, re-assign `scene_frame_set` to use default + # frame_set for the next calls. + # This avoids going into exception catching everytime, which could impact + # performance. + global scene_frame_set + scene_frame_set = _scene_frame_set_default + scene_frame_set(context, scene, frame) + + +# Default to optimized implementation of frame_set. +scene_frame_set = _scene_frame_set_optimized + + +def remap_frame_value(frame: int, scene_strip: bpy.types.SceneSequence) -> int: + """Remap `frame` in `scene_strip`'s underlying scene reference. + + :param frame: The frame to remap + :param scene_strip: The scene strip to remap to. + :returns: The remapped frame value + """ + return int(frame - scene_strip.frame_start + scene_strip.scene.frame_start) + + +def get_strips_at_frame( + frame: int, + strips: list[bpy.types.Sequence], + type_filter: Union[SequenceType, tuple[SequenceType, ...]] = None, + skip_muted: bool = True, +) -> list[bpy.types.Sequence]: + """ + Get all strips containing the given `frame` within their final range. + + :param frame: The frame value + :param strips: The strips to consider + :param type_filter: Only consider strips that are instances of the given type(s) + :param skip_muted: Whether to skip muted strips + :returns: The subset of strips matching the given parameters + """ + return [ + s + for s in strips + if ( + (not type_filter or isinstance(s, type_filter)) + and (not skip_muted or not s.mute) + and (frame >= s.frame_final_start and frame < s.frame_final_end) + ) + ] + + +def get_scene_strip_at_frame( + frame: int, + sequence_editor: bpy.types.SequenceEditor, + skip_muted: bool = True, +) -> tuple[Union[bpy.types.SceneSequence, None], int]: + """ + Get the scene strip at `frame` in `sequence_editor`'s strips with the highest + channel number. + + :param frame: The frame value + :param sequence_editor: Sequence editor containing the strips + :param skip_muted: Exclude muted strips + :returns: The scene strip (or None) and the frame in underlying scene's reference + """ + + strips = sequence_editor.sequences + channels = sequence_editor.channels + + if skip_muted: + # Exclude strips from muted channels + muted_channels = [idx for idx, channel in enumerate(channels) if channel.mute] + strips = [strip for strip in strips if not strip.channel in muted_channels] + + strips = get_strips_at_frame(frame, strips, bpy.types.SceneSequence, skip_muted) + + if not strips: + return None, frame + # Sort strips by channel + strip = sorted(strips, key=lambda x: x.channel)[-1] + + # Help type checking: strip can only be a SceneSequence here + assert isinstance(strip, bpy.types.SceneSequence) + + # Only consider scene strips with a valid scene + if not strip.scene: + return None, frame + + # Compute frame in underlying scene's reference + return strip, remap_frame_value(frame, strip) + + +@contextmanager +def scene_change_manager(context: bpy.types.Context): + """ + A context manager for saving/restoring states when changing a window's active Scene. + + :param context: The current context + """ + sync_settings = get_sync_settings() + tool_settings = context.window.scene.tool_settings + + def get_attrs(obj: object, names: list[str]) -> dict[str, Any]: + """Get several named attributes from an object as a {name: value} dict.""" + return {name: getattr(obj, name) for name in names} if obj else {} + + def set_attrs(obj: object, attrs: dict[str, Any]): + """Set several named attributes on an object.""" + if not obj: + return + for key, value in attrs.items(): + if getattr(obj, key, None) != value: + setattr(obj, key, value) + + # Store settings + if sync_settings.keep_gpencil_tool_settings: + paint_settings = get_attrs( + tool_settings.gpencil_paint, ("brush", "color_mode", "palette") + ) + sculpt_settings = get_attrs(tool_settings.gpencil_sculpt_paint, ("brush",)) + edit_settings = get_attrs(tool_settings, ("gpencil_selectmode_edit",)) + + # Safely get GP paint option set by SPA 2D animation addon. + if hasattr(context.window.scene, "gp_paint_color"): + scene_settings = get_attrs( + context.window.scene.gp_paint_color, ("mode", "vertex_color_style") + ) + + # Store the active GP material and mode if any + gp_material = None + + if context.active_object and isinstance( + context.active_object.data, bpy.types.GreasePencil + ): + gp_material = context.active_object.active_material + sync_settings.last_gp_mode = context.active_object.mode + + yield + + # Apply settings + if sync_settings.keep_gpencil_tool_settings: + tool_settings = context.window.scene.tool_settings + set_attrs(tool_settings.gpencil_paint, paint_settings) + set_attrs(tool_settings.gpencil_sculpt_paint, sculpt_settings) + set_attrs(tool_settings, edit_settings) + if hasattr(context.window.scene, "gp_paint_color"): + set_attrs(context.window.scene.gp_paint_color, scene_settings) + + # If the new active object is a GP, restore the previously stored material + # as active if also assigned. + if (gpencil := context.active_object) and isinstance( + gpencil.data, bpy.types.GreasePencil + ): + if gp_material: + material_idx = gpencil.data.materials.find(gp_material.name) + if material_idx >= 0: + gpencil.active_material_index = material_idx + + gp_mode = sync_settings.last_gp_mode + if gp_mode and gpencil.mode != gp_mode: + set_gpencil_mode_safe(context, gpencil, gp_mode) + + +def set_gpencil_mode_safe( + context: bpy.types.Context, gpencil: bpy.types.Object, mode: str +): + """ + Safely set the interaction `mode` on `gpencil` by handling inconsistencies + between object mode and internal gpencil data flags that could lead to errors. + + :param context: The current context + :param gpencil: The grease pencil object to set the mode of + :param mode: The mode to activate + """ + # Override context: "object.mode_set" uses view_layer's active object + with context.temp_override( + scene=context.window.scene, + view_layer=context.window.scene.view_layers[0], + active_object=gpencil, + ): + # There might be inconsistencies between the object interaction mode and + # the mode flag on the gp data itself (is_stroke_{modename}_mode). + # When that happens, switching to the mode fails. + # If data flag for this mode is already activated, switch to the safe + # 'EDIT_GPENCIL' mode first as a workaround to sync back the flags. + if mode not in ("OBJECT", "EDIT_GPENCIL"): + # Get short name for this mode to build data flag + # e.g: PAINT_GPENCIL => paint + mode_short = mode.split("_")[0].lower() + # Switch to edit mode first if data flags is activated + if getattr(gpencil.data, f"is_stroke_{mode_short}_mode", False): + bpy.ops.object.mode_set(mode="EDIT_GPENCIL") + # Switch the object interaction mode + bpy.ops.object.mode_set(mode=mode) + + +def get_sync_master_strip( + use_cache: bool = False, +) -> tuple[Union[bpy.types.SceneSequence, None], int]: + """ + Return the scene strip currently used by the Timeline Synchronization. + + :param use_cache: If True, return last cached value. Compute from current master time otherwise. + """ + settings = get_sync_settings() + master_scene = settings.master_scene + if not settings.enabled or not master_scene or not master_scene.sequence_editor: + return None, -1 + + if use_cache: + return ( + master_scene.sequence_editor.sequences.get(settings.last_master_strip), + settings.last_strip_scene_frame, + ) + + return get_scene_strip_at_frame( + master_scene.frame_current, + master_scene.sequence_editor, + ) + + +def update_preview_range(scene_strip: bpy.types.SceneSequence): + """Update `scene_strip`'s scene preview range to match `scene_strip`'s range. + + :param scene_strip: The scene strip to update. + """ + # Discard scene strip without scene + if not scene_strip.scene: + return + + # Ensure strip's scene is using preview range. + # NOTE: This has to be done only if it's not the case, otherwise playback + # performance are degraded due to UI updates caused by this action. + if not scene_strip.scene.use_preview_range: + scene_strip.scene.use_preview_range = True + + # Compute and update preview range if necessary + start = remap_frame_value(scene_strip.frame_final_start, scene_strip) + end = remap_frame_value(scene_strip.frame_final_end, scene_strip) - 1 + if start != scene_strip.scene.frame_preview_start: + scene_strip.scene.frame_preview_start = start + if end != scene_strip.scene.frame_preview_end: + scene_strip.scene.frame_preview_end = end + + +def sync_system_update(context: bpy.types.Context, force: bool = False): + """Perform the synchronization system update. + + :param context: The active context. + :param force: Whether to force the update, even if time did not change. + """ + + # Discard windows without scene (may happen during render) + if not context.window: + return + + sync_settings = get_sync_settings() + master_scene = sync_settings.master_scene + win_scene = context.window.scene + + # Discard update if disabled or not properly configured + if ( + not sync_settings.enabled + or not master_scene + or not master_scene.sequence_editor + ): + return + + # In order to evaluate if the master scene's current frame has changed, + # we current have to rely on a system that stores the last frame values + # that triggered a change. + # This is a temporary solution that will be replaced when the + # frame_change_post callback recieve the correct Scene. + master_time_changed = sync_settings.last_master_frame != master_scene.frame_current + scene_time_changed = sync_settings.last_strip_scene_frame != win_scene.frame_current + + # Take `force` update into account. + master_time_changed |= force + + # Discard update if master scene's current frame is similar to cached frame value + if not sync_settings.bidirectional and not master_time_changed: + return + + if sync_settings.bidirectional and not master_time_changed: + # Return if time has not changed in active scene + if not scene_time_changed: + return + + # Get sync master strip. + # NOTE: use cached value as a convenient shortcut to avoid computing it again. + strip = master_scene.sequence_editor.sequences.get( + sync_settings.last_master_strip + ) + + # Return if the master strip does not match currently active scene. + if not strip or strip.scene != win_scene: + return + + # Compute offset between scene's previous and current frame values + offset = win_scene.frame_current - sync_settings.last_strip_scene_frame + + # Evaluate strip in master scene when applying this offset + new_strip, _ = get_scene_strip_at_frame( + master_scene.frame_current + offset, + master_scene.sequence_editor, + ) + + # No strip is available: stop here. + if not new_strip: + return + + # Strip at target time is valid but differs from current master strip: + if new_strip != strip: + + # Discard changes coming from outside the strip range. + if sync_settings.last_strip_scene_frame_out_of_range: + return + + # Compute strip range in scene's referential + frame_start = remap_frame_value(strip.frame_final_start, strip) + frame_end = remap_frame_value(strip.frame_final_end - 1, strip) + + # Compute new strip range in current strip referential + new_strip_start = remap_frame_value(new_strip.frame_final_start, strip) + new_strip_end = remap_frame_value(new_strip.frame_final_end - 1, strip) + + # If current frame is not consecutive to current strip boundary + # or equal to one of the new strip's boundary, + # identify as out of range and discard update. + if win_scene.frame_current not in ( + frame_start - 1, + frame_end + 1, + new_strip_start, + new_strip_end, + ): + sync_settings.last_strip_scene_frame_out_of_range = True + return + + # Only tolerate offset values of 1 to jump between strips. + if abs(offset) != 1: + return + + # Don't update master time if user is scrubbing through the timeline + # or if the animation is playing to avoid unwanted scene switching. + if context.screen.is_scrubbing or context.screen.is_animation_playing: + return + + # Apply the offset to master scene, and return. + # The master scene and cache update will happen in the next frame_change_post + # handler call. + scene_frame_set(context, master_scene, master_scene.frame_current + offset) + return + + # Update cached frame cache value + sync_settings.last_master_frame = master_scene.frame_current + + # Get scene strip at current frame + strip, inner_frame = get_scene_strip_at_frame( + master_scene.frame_current, + master_scene.sequence_editor, + ) + # Discard update if no such strip exists + if not strip: + sync_settings.last_master_strip = "" + sync_settings.last_master_strip_idx = -1 + sync_settings.last_strip_scene_frame = -1 + return + + # Update cached values + sync_settings.last_master_strip = strip.name + sync_settings.last_master_strip_idx = master_scene.sequence_editor.sequences.find( + strip.name + ) + sync_settings.last_strip_scene_frame = inner_frame + sync_settings.last_strip_scene_frame_out_of_range = False + + # Update strip's underlying scene frame before making it active in context's window + # to avoid unwanted updates in case bidirectional sync is enabled. + if strip.scene.frame_current != inner_frame: + scene_frame_set(context, strip.scene, inner_frame) + + if sync_settings.use_preview_range: + # Update scene's preview range. + update_preview_range(strip) + + # Synchronize target windows + for window in ( + context.window_manager.windows + if sync_settings.sync_all_windows + else [context.window] + ): + # If window's scene is explicitly set to master scene, don't update it. + if not bpy.app.background and window.scene == master_scene: + continue + # Open strip's scene in window at the remapped frame + if window.scene != strip.scene: + # Use scene_change_manager to optionnaly keep tool settings between scenes + with scene_change_manager(context): + window.scene = strip.scene + # Use strip camera if specified + if strip.scene_camera and window.scene.camera != strip.scene_camera: + window.scene.camera = strip.scene_camera + + if sync_settings.active_follows_playhead: + if master_scene.sequence_editor.active_strip != strip: + master_scene.sequence_editor.active_strip = strip + + +@bpy.app.handlers.persistent +def on_frame_changed(scene: bpy.types.Scene, depsgraph: bpy.types.Depsgraph): + # Early return when context is still a restricted context + if not isinstance(bpy.context, bpy.types.Context): + return + + # Update Timeline Synchronization system + sync_system_update(bpy.context) + + +def update_sync_cache_from_current_state(): + """ + Update Timeline Synchronization cached values based on file's current state. + """ + sync_settings = get_sync_settings() + sync_settings.last_master_frame = sync_settings.master_scene.frame_current + strip, frame = get_sync_master_strip() + sync_settings.last_master_strip = strip.name if strip else "" + sync_settings.last_master_strip_idx = ( + sync_settings.master_scene.sequence_editor.sequences.find(strip.name) + if strip + else -1 + ) + sync_settings.last_strip_scene_frame = frame + sync_settings.last_strip_scene_frame_out_of_range = not strip + + +@bpy.app.handlers.persistent +def on_load_pre(*args): + sync_settings = get_sync_settings() + # Reset Timeline Synchronization settings + sync_settings.enabled = False + sync_settings.master_scene = None + sync_settings.last_master_frame = -1 + sync_settings.last_master_strip = "" + sync_settings.last_master_strip_idx = -1 + sync_settings.last_strip_scene_frame = -1 + sync_settings.last_strip_scene_frame_out_of_range = True + sync_settings.last_gp_mode = "" + + +@bpy.app.handlers.persistent +def on_load_post(*args): + sync_settings = get_sync_settings() + # Auto-setup the system for the new file if the active screen contains + # a Sequence Editor area defining a scene override with at least 1 scene strip. + for area in bpy.context.screen.areas: + space = area.spaces.active + if isinstance(space, bpy.types.SpaceSequenceEditor) and getattr( + space, "scene_override", False + ): + seq_editor = area.spaces.active.scene_override.sequence_editor + if seq_editor and any( + isinstance(s, bpy.types.SceneSequence) for s in seq_editor.sequences + ): + sync_settings.master_scene = area.spaces.active.scene_override + sync_settings.enabled = True + update_sync_cache_from_current_state() + break + + +@bpy.app.handlers.persistent +def on_undo_redo(scene, _): + """Undo/Redo post handler callback.""" + sync_settings = get_sync_settings() + if not sync_settings.enabled: + return + + # Explicitly update cached values on undo/redo events since + # they don't re-trigger frame change events. + # Two update cases: + # 1. Event comes from master scene and master time has changed + # 2. (Bidirectional ON) Event comes from window's scene and scene time has changed + if ( + scene == sync_settings.master_scene + and sync_settings.master_scene.frame_current != sync_settings.last_master_frame + ) or ( + sync_settings.bidirectional + and scene == bpy.context.window.scene + and scene.frame_current != sync_settings.last_strip_scene_frame + ): + update_sync_cache_from_current_state() + + +classes = (TimelineSyncSettings,) + + +def register(): + register_classes(classes) + + # Store Synchronization settings on the WindowManager type + # NOTE: this is not saved in the Blender file + bpy.types.WindowManager.timeline_sync_settings = bpy.props.PointerProperty( + type=TimelineSyncSettings, + name="Timeline Synchronization Settings", + ) + + # React to scenes current frame changes + bpy.app.handlers.frame_change_post.append(on_frame_changed) + # React to file opening + bpy.app.handlers.load_pre.append(on_load_pre) + bpy.app.handlers.load_post.append(on_load_post) + + bpy.app.handlers.undo_post.append(on_undo_redo) + bpy.app.handlers.redo_post.append(on_undo_redo) + + +def unregister(): + unregister_classes(classes) + + del bpy.types.WindowManager.timeline_sync_settings + bpy.app.handlers.frame_change_post.remove(on_frame_changed) + bpy.app.handlers.load_pre.remove(on_load_pre) + bpy.app.handlers.load_post.remove(on_load_post) + + bpy.app.handlers.undo_post.remove(on_undo_redo) + bpy.app.handlers.redo_post.remove(on_undo_redo) @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.sync.core import get_sync_settings, sync_system_update + +from spa_sequencer.utils import register_classes, unregister_classes + + +class WM_OT_timeline_sync_toggle(bpy.types.Operator): + bl_idname = "wm.timeline_sync_toggle" + bl_label = "Toggle Timeline Synchronization" + bl_description = "Toggle Timeline Synchronization System" + bl_options = set() + + def execute(self, context: bpy.types.Context): + sync_settings = get_sync_settings() + sync_settings.enabled = not sync_settings.enabled + + # Setup with active space's data if applicable + if ( + sync_settings.enabled + and isinstance(context.space_data, bpy.types.SpaceSequenceEditor) + and context.space_data.scene_override + ): + # Use overriden scene defined in the SpaceSequence editor as master scene + sync_settings.master_scene = context.space_data.scene_override + + # Trigger sync system update + sync_system_update(context) + + return {"FINISHED"} + + +class WM_OT_timeline_sync_play_master(bpy.types.Operator): + bl_idname = "wm.timeline_sync_play_master" + bl_label = "Play Master Scene" + bl_description = "Toggle playback of master scene" + bl_options = set() + + bl_keymaps = [ + { + "space_type": "EMPTY", + "category_name": "Frames", + "key": "SPACE", + "shift": True, + } + ] + + @classmethod + def poll(cls, context: bpy.types.Context): + return get_sync_settings().master_scene is not None + + def execute(self, context: bpy.types.Context): + # Trigger playback on master scene using context override. + with context.temp_override(scene=get_sync_settings().master_scene): + bpy.ops.screen.animation_play("INVOKE_DEFAULT") + return {"FINISHED"} + + +classes = ( + WM_OT_timeline_sync_toggle, + WM_OT_timeline_sync_play_master, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from spa_sequencer.sync.core import get_sync_settings +from spa_sequencer.utils import register_classes, unregister_classes + + +class SEQUENCER_PT_SyncPanel(bpy.types.Panel): + """Timeline Synchronization Panel.""" + + bl_label = "Timeline Synchronization" + bl_space_type = "SEQUENCE_EDITOR" + bl_region_type = "UI" + bl_category = "SPA.Sequencer" + + def draw(self, context): + self.layout.use_property_split = True + self.layout.use_property_decorate = False + settings = get_sync_settings() + self.layout.operator( + "wm.timeline_sync_toggle", + text="Synchronize", + icon="UV_SYNC_SELECT", + depress=settings.enabled, + ) + self.layout.prop(settings, "master_scene") + + +class SEQUENCER_PT_SyncPanelAdvancedSettings(bpy.types.Panel): + """Timeline Synchronization advanced settings Panel.""" + + bl_label = "Advanced Settings" + bl_parent_id = "SEQUENCER_PT_SyncPanel" + bl_space_type = "SEQUENCE_EDITOR" + bl_region_type = "UI" + bl_category = "SPA.Sequencer" + + def draw(self, context): + settings = get_sync_settings() + self.layout.prop(settings, "keep_gpencil_tool_settings") + self.layout.prop(settings, "bidirectional") + self.layout.prop(settings, "use_preview_range") + self.layout.prop(settings, "sync_all_windows") + self.layout.prop(settings, "active_follows_playhead") + + +classes = ( + SEQUENCER_PT_SyncPanel, + SEQUENCER_PT_SyncPanelAdvancedSettings, +) + + +def register(): + register_classes(classes) + + +def unregister(): + unregister_classes(classes) @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +""" +Addon level utility functions. +""" + +import logging +import os +import re +from typing import Type + +import bpy + +import spa_sequencer +from spa_sequencer.keymaps import register_keymap + +BlenderTypeClass = Type[bpy.types.bpy_struct] +BlenderOperatorTypeClass = Type[bpy.types.Operator] + +log = logging.getLogger(__name__) + + +def register_classes(classes: tuple[BlenderTypeClass, ...]): + """ + Register multiple subclasses of a Blender type class. + + For operators, if: + + * `bl_keymaps`: list of keymaps definitions as dicts + * `bl_keymaps_defaults`: optional, keymap definition dict for common values + + are specified within the class definition, create the corresponding keymaps. + Keymap definitions keys are the parameters of spa_sequencer.keymaps.register_keymap. + + For instance, the code sample below binds 2 keymaps to an operator: + + .. code-block:: python + + SEQUENCER_OT_operator_example(bpy.Types.Operator): + bl_idname = "sequencer.operator_example" + ... + bl_keymaps_defaults = { + "space_type": "SEQUENCE_EDITOR", "category_name": "Sequencer" + } + bl_keymaps = [ + {"key": "F", "ctrl": True, "properties": {"value" : 10}} + {"key": "F", "alt": True, "properties": {"value" : -10}} + ] + value: bpy.props.IntProperty(...) + + + :param classes: the classes to register + """ + + def register_operator_keymaps(op: BlenderOperatorTypeClass): + """Register the keymaps attached to an operator class.""" + keymaps = getattr(op, "bl_keymaps", []) + defaults = getattr(op, "bl_keymaps_defaults", {}) + + if not isinstance(keymaps, list): + raise TypeError("bl_keymaps: expected a list") + + if not isinstance(defaults, dict): + raise TypeError("bl_keymaps_defaults: expected a dict") + + for keymap in keymaps: + kwargs = defaults.copy() + # Add operator's name to the arguments + keymap["operator"] = op.bl_idname + # Update defaults with current keymap arguments + kwargs.update(keymap) + try: + register_keymap(**kwargs) + except Exception as e: + log.warning(f"Failed to register {op.bl_idname} keymap: {e}") + continue + + for c in classes: + bpy.utils.register_class(c) + if issubclass(c, (bpy.types.Operator, bpy.types.Macro)): + try: + register_operator_keymaps(c) + except Exception as e: + log.warning(f"Failed to register {c.bl_idname} keymaps: {e}") + + +def unregister_classes(classes: tuple[BlenderTypeClass, ...], reverse=True): + """Unregister multiple subclasses of a Blender type class. + + :param classes: the classes to unregister + """ + for c in reversed(classes) if reverse else classes: + bpy.utils.unregister_class(c) + + +def remove_auto_numbering_suffix(name: str) -> str: + """Remove Blender's auto numbering suffix from `name`. + + :param name: The name to consider + :return: The name without numbering suffix if any + """ + return re.sub(r".\d{3}$", "", name) + + +def get_addon_directory() -> str: + return os.path.dirname(spa_sequencer.__file__) @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import pytest + +import bpy + + +@pytest.fixture() +def addon(): + """Fixture loading/enabling the addon.""" + import addon_utils + + return addon_utils.enable("spa_sequencer", persistent=True, default_set=True) + + +@pytest.fixture() +def new_file(): + """Fixture loading the default template file.""" + bpy.ops.wm.read_homefile(app_template="") + + +@pytest.fixture(autouse=True) +def setup_test(new_file, addon): + """ + Default setup for unit test functions that orchestrate fixtures to: + - open default scene from empty app template + - load the addon + """ + assert addon is not None + yield @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + + +def test_load_addon(): + """Ensure addon loads correctly.""" + import addon_utils + + assert addon_utils.check("spa_sequencer")[1] @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import pytest + +import bpy + +from spa_sequencer.shared_folders import core + + +def test_shared_folders_root(): + # Initially, the root collection does not exist + assert core.get_shared_folders_root_collection() is None + # Create it and ensure it's valid + root_collection = core.get_or_create_shared_folders_root_collection() + assert core.get_shared_folders_root_collection() == root_collection + + +def test_make_shared_folder_from_collection(): + # Turn a collection in the default scene into a share collection + col = bpy.context.scene.collection.children[0] + core.make_shared_folder_from_collection(col) + # Ensure it is considered as such + assert core.is_shared_folder(col) + + +def test_make_shared_folder_from_scene_collection(): + # Ensure a scene's master collection cannot be used as a shared folder + with pytest.raises(ValueError): + core.make_shared_folder_from_collection(bpy.context.scene.collection) + + +def test_create_new_shared_folder(): + # Create a new shared folder from a name + folder_name = "SharedFolder" + collection = core.create_shared_folder(folder_name) + # Ensure it has been properly registered as a shared collection + assert core.is_shared_folder(collection) + assert core.get_shared_folder_by_name(folder_name) == collection + + +def test_get_invalid_shared_folder(): + # The root collection for shared folder does not exist, this raises RuntimeError + with pytest.raises(RuntimeError): + core.get_shared_folder_by_name("FakeFolder") + + # Create the root collection + core.get_or_create_shared_folders_root_collection() + + # The shared folder does not exist, this raises ValueError + with pytest.raises(ValueError): + core.get_shared_folder_by_name("FakeFolder") + + +def test_link_in_scene(): + # Create a shared folder from existing collection + col = bpy.context.scene.collection.children[0] + core.make_shared_folder_from_collection(col) + # Link it to the current scene + core.link_shared_folder(col, [bpy.context.scene]) + # Ensure the collection is linked in the scene's collection + assert col in bpy.context.scene.collection.children.values() + + +def test_create_and_link_in_scene(): + # Create a shared folder and link it in current scene + col, _ = core.create_and_link_shared_folder("SharedFolder", [bpy.context.scene]) + + # Ensure collection is a shared folder and that it is linked in the scene + assert core.is_shared_folder(col) + assert col in bpy.context.scene.collection.children.values() + + +def test_create_link_unlink_in_multiple_scenes(): + # Create several scenes + scenes = [bpy.data.scenes.new(name=f"Scene{i}") for i in range(3)] + + # Create a shared folder and link it in those scenes + col, _ = core.create_and_link_shared_folder("SharedFolder", scenes) + assert core.is_shared_folder(col) + + # Ensure the collection is in each scene's collection + for scene in scenes: + assert col in scene.collection.children.values() + + # This should not raise (but does not do anything) + core.link_shared_folder(col, scenes) + + # Unlink shared folders + core.unlink_shared_folder(col, scenes) + + for scene in scenes: + assert col not in scene.collection.children.values() + + # The only user left should be the shared folder root collection + assert col.users == 1 + + +def test_delete_shared_folder(): + folder_name = "SharedFolder" + collection = core.create_shared_folder(folder_name) + core.delete_shared_folder(collection) + + with pytest.raises(ReferenceError): + getattr(collection, "name") + + with pytest.raises(ValueError): + core.get_shared_folder_by_name(folder_name) @@ -0,0 +1,415 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import pytest + +import bpy + +from spa_sequencer.shot.core import ( + adjust_shot_duration, + delete_scene, + duplicate_scene, + DuplicationManifest, + rename_scene, + slip_shot_content, +) + + +from utils import create_shot_scene + + +def test_scene_duplication_same_name(): + ref_scene = bpy.context.scene + with pytest.raises(ValueError): + duplicate_scene(bpy.context, ref_scene, ref_scene.name) + + +def test_scene_duplication(): + ref_scene = bpy.context.scene + # Create a new scene named "SceneCopy" + new_scene = duplicate_scene(bpy.context, ref_scene, "SceneCopy") + + # Check collection duplication + assert len(new_scene.collection.children) == len(ref_scene.collection.children) + # Check object duplication + assert len(new_scene.objects) == len(ref_scene.objects) + # Check data duplication + assert ref_scene.objects[0].data != new_scene.objects[0].data + + # Add an object to "SceneCopy"'s root collection + obj_name = "Empty_{}" + new_obj = bpy.data.objects.new( + name=obj_name.format(new_scene.name), object_data=None + ) + new_scene.collection.objects.link(new_obj) + # Create another scene from "SceneCopy" + new_scene2 = duplicate_scene(bpy.context, new_scene, "SceneCopy2") + # Check that this last scene has been created from "SceneCopy" + assert len(new_scene2.objects) == len(new_scene.objects) != len(ref_scene.objects) + # Check datablack auto-renaming + assert obj_name.format(new_scene2.name) in new_scene2.objects + + +def test_scene_duplication_animation_data(): + ref_scene = bpy.context.scene + + # Keyframe active object + ref_obj = ref_scene.view_layers[0].objects.active + ref_obj.keyframe_insert(data_path="location", frame=1) + + # Duplicate the scene + new_scene = duplicate_scene(bpy.context, ref_scene, "SceneCopy") + + # Ensure new scene's active object has animation data + new_obj = new_scene.view_layers[0].objects.active + assert new_obj.animation_data and new_obj.animation_data.action + + ref_action = ref_obj.animation_data.action + new_action = new_obj.animation_data.action + # Check action duplication + assert ref_action != new_action + # Check that fcurves are preserved + assert len(ref_action.fcurves) == len(new_action.fcurves) + + +def test_scene_duplication_hierarchy(): + ref_scene = bpy.context.scene + + # Create a few objects and link them to the active scene collection + obj1 = bpy.data.objects.new(name="Obj1", object_data=None) + obj2 = bpy.data.objects.new(name="Obj2", object_data=None) + obj3 = bpy.data.objects.new(name="Obj3", object_data=None) + + ref_scene.collection.objects.link(obj1) + ref_scene.collection.objects.link(obj2) + ref_scene.collection.objects.link(obj3) + + # Parent source objects + obj3.parent = obj2 + obj2.parent = obj1 + + # Duplicate the active scene + manifest = DuplicationManifest() + duplicate_scene(bpy.context, ref_scene, "SceneCopy", manifest) + + # Ensure parenting hierarchy is preserved + assert manifest[obj2].parent == manifest[obj1] + assert manifest[obj3].parent == manifest[obj2] + + +def test_scene_duplication_modifier_remapping(): + ref_scene = bpy.context.scene + + # Create a modifier with a reference to an object in the scene + obj = bpy.context.active_object + mod = obj.modifiers.new(name="Cast", type="CAST") + mod.object = bpy.context.scene.camera + + # Duplicate the scene + manifest = DuplicationManifest() + duplicate_scene(bpy.context, ref_scene, "SceneCopy", manifest) + + # Ensure modifier's object reference has been remapped + assert manifest[obj].modifiers[0].object == manifest[mod.object] + + +def test_scene_duplication_constraint_remapping(): + ref_scene = bpy.context.scene + + # Create a constraint with a reference to an object in the scene + obj = bpy.context.active_object + constraint = obj.constraints.new(type="TRACK_TO") + constraint.target = bpy.context.scene.camera + + # Duplicate the scene + manifest = DuplicationManifest() + duplicate_scene(bpy.context, ref_scene, "SceneCopy", manifest) + + # Ensure constraint's target has been remapped + assert manifest[obj].constraints[0].target == manifest[constraint.target] + + +def test_scene_duplication_driver_remapping(): + ref_scene = bpy.context.scene + + obj = bpy.context.active_object + # Create a driver with targets referencing object and data + fcurve = obj.driver_add("location", 0) + driver = fcurve.driver + varA = driver.variables.new() + varA.targets[0].id_type = "OBJECT" + varA.targets[0].id = bpy.context.scene.camera + varB = driver.variables.new() + varB.targets[0].id_type = "CAMERA" + varB.targets[0].id = bpy.context.scene.camera.data + + # Duplicate the scene + manifest = DuplicationManifest() + duplicate_scene(bpy.context, ref_scene, "SceneCopy", manifest) + + # Ensure duplicated driver's targets references have been remapped + new_driver = manifest[obj].animation_data.drivers[0].driver + assert new_driver.variables[0].targets[0].id == manifest[varA.targets[0].id] + assert new_driver.variables[1].targets[0].id == manifest[varB.targets[0].id] + + +def test_scene_duplication_gp_modifier_and_effect_remapping(): + ref_scene = bpy.context.scene + + # Create a GP object with modifier and FX referencing an object in the scene + bpy.ops.object.add(type="GPENCIL") + obj = bpy.context.active_object + mod = obj.grease_pencil_modifiers.new(name="Mirror", type="GP_MIRROR") + mod.object = bpy.context.scene.camera + fx = obj.shader_effects.new(name="Shadow", type="FX_SHADOW") + fx.object = bpy.context.scene.camera + + # Duplicate the scene + manifest = DuplicationManifest() + duplicate_scene(bpy.context, ref_scene, "SceneCopy", manifest) + + # Ensure GP modifier and effect's object references have been remapped + assert manifest[obj].grease_pencil_modifiers[0].object == manifest[mod.object] + assert manifest[obj].shader_effects[0].object == manifest[fx.object] + + +def test_scene_rename_empty_object(): + scene = bpy.context.scene + # Create an object without data and add scene name to its name + bpy.ops.object.add(type="EMPTY") + bpy.context.active_object.name += f".{scene.name}" + + # Rename the scene + new_scene_name = "SceneRenamed" + rename_scene(scene, new_scene_name) + + # Ensure the active object was properly renamed + assert bpy.context.active_object.name.endswith(new_scene_name) + + +def test_scene_rename_datablocks(): + scene = bpy.context.scene + new_name = "Shot" + + # Append the scene's name to all datablocks in active collection + suffix = f"{scene.name}" + col = bpy.context.collection + + col.name += suffix + + for obj in col.objects: + # Create an animation to trigger the creation of an action + obj.keyframe_insert(data_path="location", frame=1) + obj.name += suffix + obj.data.name += suffix + obj.animation_data.action.name += suffix + + # Rename the scene + rename_scene(scene, new_name) + + # Ensure all datablocks have been properly renamed + assert scene.name == new_name + assert col.name.endswith(new_name) + for obj in col.objects: + for datablock in (obj, obj.data, obj.animation_data.action): + assert datablock.name.endswith(new_name) + + +def test_scene_rename_invalid_name(): + ref_scene_name = bpy.context.scene.name + + bpy.ops.scene.new(type="EMPTY") + + # Ensure using an already existing scene name fails + with pytest.raises(ValueError): + rename_scene(bpy.context.scene, ref_scene_name) + + +def test_scene_delete_scene_duplicate(): + # Duplicate the default scene + manifest = DuplicationManifest() + sceneA = duplicate_scene(bpy.context, bpy.context.scene, "SceneA", manifest) + + # Delete this new scene + del_count = delete_scene(sceneA, True) + + # Ensure by count that all created datablocks were deleted + assert del_count == len(manifest) + # Iterate over manifest and ensure that all datablocks were deleted. + # Accessing them must throw a ReferenceError. + for datablock in manifest.values(): + with pytest.raises(ReferenceError): + getattr(datablock, "bl_rna") + + +def test_scene_delete_scene_duplicate_with_shared_collection(): + # Duplicate default scene + manifest = DuplicationManifest() + sceneA = duplicate_scene(bpy.context, bpy.context.scene, "SceneA", manifest) + # Link a collection from the default scene into the new scene + shared_col = bpy.context.scene.collection.children[0] + sceneA.collection.children.link(shared_col) + + # Delete the scene + del_count = delete_scene(sceneA, True) + + # Ensure that only datablocks from the duplication process were deleted + assert del_count == len(manifest) + for datablock in manifest.values(): + with pytest.raises(ReferenceError): + getattr(datablock, "bl_rna") + + # The shared collection should still be valid + assert shared_col.bl_rna + + +def test_shot_duration_adjust_positive_offset(): + # Create a shot + sh1 = create_shot_scene(bpy.context.scene, 1, 1) + sh1_original_duration = sh1.frame_final_duration + + # Adjust duration: use a positive offset + offset = 10 + adjust_shot_duration(sh1, offset) + + # Strip's final duration and internal scene range should have been impacted + assert sh1.frame_final_duration == sh1_original_duration + offset + assert sh1.scene.frame_end == sh1.scene.frame_start + sh1.frame_final_duration - 1 + + +def test_shot_duration_adjust_negative_offset(): + # Create a shot + sh1 = create_shot_scene(bpy.context.scene, 1, 1) + original_frame_end = sh1.scene.frame_end + + # Adjust duration: use a negative offset + sh1_new_duration = 10 + offset = sh1_new_duration - sh1.frame_final_duration + adjust_shot_duration(sh1, offset) + + # Strip's final duration should have been impacted + assert sh1.frame_final_duration == sh1_new_duration + # Internal scene range should have been preserved (shorter duration) + assert sh1.scene.frame_end == original_frame_end + + +def test_shot_duration_adjust_negative_offset_clamp(): + # Create a shot + sh1 = create_shot_scene(bpy.context.scene, 1, 1) + original_frame_end = sh1.scene.frame_end + + # Adjust duration: use a negative offset leading to a null duration + sh1_new_duration = 0 + offset = sh1_new_duration - sh1.frame_final_duration + adjust_shot_duration(sh1, offset) + + # Duration should have been clamped to 1 + assert sh1.frame_final_duration == 1 + # Internal scene range should have been preserved (shorter duration) + assert sh1.scene.frame_end == original_frame_end + + +def test_shot_duration_adjust_from_start_negative_offset(): + # Create a shot + sh1 = create_shot_scene(bpy.context.scene, 1, 1) + + duration = sh1.frame_final_duration + # Adjust duration from frame start + offset = -10 + adjust_shot_duration(sh1, offset, from_frame_start=True) + + # Strip start frame should not have changed + assert sh1.frame_final_start == 1 + # Strip content should have been offset + assert sh1.frame_offset_start == -offset + assert sh1.frame_final_duration == duration + offset + + +def test_shot_duration_adjust_from_start_negative_offset_clamp(): + # Create a shot + sh1 = create_shot_scene(bpy.context.scene, 1, 1) + + duration = sh1.frame_final_duration + # Adjust duration from frame start with an offset going above frame end + offset = -duration * 2 + adjust_shot_duration(sh1, offset, from_frame_start=True) + + # Strip start frame should not have changed + assert sh1.frame_final_start == 1 + # Strip content offset should have been clamped to have a minimum duration of 1 + assert sh1.frame_offset_start == duration - 1 + assert sh1.frame_final_duration == 1 + + +def test_shot_duration_adjust_from_start_positive_offset_clamp(): + # Create a shot + sh1 = create_shot_scene(bpy.context.scene, 1, 1) + + duration = sh1.frame_final_duration + # Adjust duration from frame start with a positive offset going above strip's + # scene frame start. + offset = 1 + adjust_shot_duration(sh1, offset, from_frame_start=True) + + # Strip start frame should not have changed + assert sh1.frame_final_start == 1 + # Offset should have been clamped to stay in scene's range + assert sh1.frame_offset_start == 0 + # Duration should not have changed + assert sh1.frame_final_duration == duration + + +def test_shot_duration_adjust_with_multiple_shots(): + # Create a sequence with 3 shots following each others + sh1 = create_shot_scene(bpy.context.scene, 1, bpy.context.scene.frame_start) + sh2 = create_shot_scene(bpy.context.scene, 1, sh1.frame_final_end) + sh3 = create_shot_scene(bpy.context.scene, 1, sh2.frame_final_end) + + # Retime the shot in between + sh2_original_frame_end = sh2.frame_final_end + offset = 10 + adjust_shot_duration(sh2, offset) + + # 1st shot should not have been impacted + assert sh1.frame_final_start == bpy.context.scene.frame_start + # 2nd shot's duration should have changed + assert sh2.frame_final_end == sh2_original_frame_end + offset + # 3rd shot should have been shifted + assert sh3.frame_final_start == sh2.frame_final_end + + +def test_shot_slip_content_positive_offset(): + # Test strip + sh1 = create_shot_scene(bpy.context.scene, 1, bpy.context.scene.frame_start) + # Witness strip + sh2 = create_shot_scene(bpy.context.scene, 2, bpy.context.scene.frame_start) + + offset = 10 + slip_shot_content(sh2, offset) + + # Ensure strip frame_start members were propertly updated + assert sh2.frame_offset_start == sh1.frame_offset_start + offset + assert sh2.frame_start == sh1.frame_start - offset + assert sh2.frame_final_start == sh1.frame_final_start + + +def test_shot_slip_content_negative_offset(): + # Test strip + sh1 = create_shot_scene(bpy.context.scene, 1, bpy.context.scene.frame_start) + # Witness strip + sh2 = create_shot_scene(bpy.context.scene, 2, bpy.context.scene.frame_start) + + offset = -10 + + # Strip's internal range start at internal frame start. + # With clamp_start enabled, this sould not change anything. + slip_shot_content(sh2, offset, clamp_start=True) + assert sh2.frame_offset_start == sh1.frame_offset_start + assert sh2.frame_start == sh1.frame_start + assert sh2.frame_final_start == sh1.frame_final_start + + # Without clamp, offset should have been applied + slip_shot_content(sh2, offset, clamp_start=False) + assert sh2.frame_offset_start == sh1.frame_offset_start + offset + assert sh2.frame_start == sh1.frame_start - offset + assert sh2.frame_final_start == sh1.frame_final_start @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import pytest + +import bpy + +from spa_sequencer.shot.naming import ShotNaming, ShotPrefix +from spa_sequencer.shot.core import duplicate_scene + + +shot_naming = ShotNaming() +# Define shot prefixes for unit testing +shot_naming.prefixes = [ShotPrefix.SHOT.value, ShotPrefix.PREVIZ.value] + + +def test_get_default_shot_name(): + assert shot_naming.default_shot_name() == "SH0010" + + +def test_get_next_shot_name_from_default(): + name = shot_naming.default_shot_name() + assert shot_naming.next_shot_name_from_name(name) == "SH0020" + + +def test_get_next_shot_name_custom_increment(): + name = shot_naming.default_shot_name() + assert shot_naming.next_shot_name_from_name(name, 5) == "SH0015" + + +def test_get_next_shot_name_snap_to_spacing(): + assert shot_naming.next_shot_name_from_name("SH0015") == "SH0020" + + +def test_get_next_shot_name_overflow(): + shot_max = shot_naming.build_shot_name(shot_naming.number_max) + with pytest.raises(ValueError): + assert shot_naming.next_shot_name_from_name(shot_max) + + +def test_build_shot_name_with_prefix(): + # Buils shot name with a valid prefix. + shot_name = shot_naming.build_shot_name(1, ShotPrefix.PREVIZ.value) + assert shot_name == f"{ShotPrefix.PREVIZ.value}0001" + # Extract shot data and ensure they match original shot name. + shot_data = shot_naming.shot_data_from_name(shot_name, strict=True) + assert shot_data.prefix == ShotPrefix.PREVIZ.value + assert shot_data.number == 1 + + +def test_build_shot_name_with_invalid_prefix(): + # Build shot name with invalid prefix. + shot_name = "INV0012" + shot_data = shot_naming.shot_data_from_name(shot_name, strict=False) + # Shot prefix has been discarded. + assert shot_data.prefix == shot_naming.prefix_default + # Shot number has been preserved + assert shot_data.number == 12 + + # Using string naming should raise an error. + with pytest.raises(ValueError): + shot_naming.shot_data_from_name(shot_name, strict=True) + + +def test_build_shot_name_with_take(): + take_A = shot_naming.build_shot_name(10, take="A") + assert take_A == "SH0010A" + assert shot_naming.next_take_name(take_A) == "SH0010B" + + +def test_build_shot_name_with_take_overflow(): + take_F = shot_naming.build_shot_name(10, take="F") + assert take_F == "SH0010F" + with pytest.raises(ValueError): + shot_naming.next_take_name(take_F) + + +def test_build_shot_name_with_invalid_take_value(): + with pytest.raises(ValueError): + shot_naming.build_shot_name(10, take="9") + + +def test_get_new_shot_name_alt_naming(): + # Create a local naming convention + naming = ShotNaming() + naming.prefixes = ["SHOT"] + naming.separator = "_" + naming.number_default = 0 + naming.number_digits = 3 + naming.number_spacing = 1 + + # Ensure first shot name is correct and respect alternative naming + assert naming.default_shot_name() == "SHOT_000" + + +def test_get_new_shot_name_no_shot_scene(): + # Rename scene to something that is not a shot + bpy.context.scene.name = "TEST" + + new_shot_name = shot_naming.next_shot_name_from_scenes() + # Ensure first shot name is correct + assert new_shot_name == shot_naming.default_shot_name() == "SH0010" + + +def test_get_new_shot_name_increment_shot_number(): + bpy.context.scene.name = "SH0010" + new_shot_name = shot_naming.next_shot_name_from_scenes() + # Ensure next shot name is correct + assert new_shot_name == "SH0020" + + +def test_get_new_shot_name_increment_highest_shot_number(): + bpy.context.scene.name = "SH0010" + + # Create a few shots + ref_scene = bpy.context.scene + duplicate_scene(bpy.context, ref_scene, "SH0015") + duplicate_scene(bpy.context, ref_scene, "SH0019") + + new_shot_name = shot_naming.next_shot_name_from_scenes() + # Ensure that the new name snaps to spacing. + assert new_shot_name == "SH0020" + duplicate_scene(bpy.context, ref_scene, "SH0020") + + new_shot_name = shot_naming.next_shot_name_from_scenes() + # Next shot should add spacing. + assert new_shot_name == "SH0030" @@ -0,0 +1,330 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + +from pytest import fixture + +from spa_sequencer.sync.core import remap_frame_value, get_sync_settings + +from utils import create_shot_scene + + +@fixture +def basic_synced_setup() -> tuple[bpy.types.Scene, bpy.types.SceneSequence]: + """ + Generate a basic setup with an edit scene and 1 shot scene of default length (250f). + It also enables the sync system. + Returns the edit scene and the shot strip. + """ + sync_settings = get_sync_settings() + + # Use default scene as edit scene and create the sequence editor + edit_scene = bpy.context.scene + edit_scene.name = "EDIT" + edit_scene.sequence_editor_create() + + shot_strip = create_shot_scene(edit_scene, 1, 1) + + # Setup and enable sync system + sync_settings.master_scene = edit_scene + sync_settings.enabled = True + + return edit_scene, shot_strip + + +@fixture +def complex_synced_setup( + basic_synced_setup, +) -> tuple[bpy.types.Scene, list[bpy.types.SceneSequence]]: + """ + Generate a complex setup with an edit scene and 4 overlapping shots on + different channels with a scene length of (250f). + Returns the edit scene and the 4 shot strips. + """ + edit_scene, shot_strip = basic_synced_setup + shot_strip_1 = shot_strip + shot_strip_2 = create_shot_scene(edit_scene, 2, shot_strip.frame_final_start) + shot_strip_3 = create_shot_scene(edit_scene, 3, shot_strip.frame_final_start) + shot_strip_4 = create_shot_scene(edit_scene, 4, shot_strip.frame_final_start) + + return (edit_scene, [shot_strip_1, shot_strip_2, shot_strip_3, shot_strip_4]) + + +def test_change_edit_time(basic_synced_setup): + edit_scene, shot_strip = basic_synced_setup + + # Shorten the strip length + shot_strip.frame_final_duration = 10 + + # Change edit scene's frame within shot's boundaries + edit_frame = 4 + edit_scene.frame_set(edit_frame) + + # Shot scene's current frame should have changed + shot_frame = remap_frame_value(edit_frame, shot_strip) + assert shot_strip.scene.frame_current == shot_frame + + # Change edit scene's frame outside shot's boundaries + edit_frame = 20 + edit_scene.frame_set(edit_frame) + # Frame should still be equal to previous value + assert shot_strip.scene.frame_current == shot_frame + + +def test_disable_sync(basic_synced_setup): + edit_scene, shot_strip = basic_synced_setup + sync_settings = get_sync_settings() + + # Disable the synchronization system + sync_settings.enabled = False + + # Change edit scene's frame within shot's boundaries + shot_frame = shot_strip.scene.frame_current + edit_scene.frame_set(4) + + # Shot frame should not have changed + assert shot_frame == shot_strip.scene.frame_current + + +def test_change_master_scene(basic_synced_setup): + edit_scene, shot_strip = basic_synced_setup + sync_settings = get_sync_settings() + + # Unset master scene + sync_settings.master_scene = None + + # Change edit scene's frame within shot's boundaries + shot_frame = shot_strip.scene.frame_current + edit_scene.frame_set(4) + + # Shot frame should not have changed + assert shot_frame == shot_strip.scene.frame_current + + +def test_window_scene_sync(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + + # Create a 2nd shot after the first one + create_shot_scene(edit_scene, 1, shot_strip_1.frame_final_end) + + # Start the tests from a neutral scene + bpy.context.window.scene = edit_scene + edit_scene.frame_set(0) + + # Moving the edit frame to the start of each strip should + # update window's scene accordingly. + for strip in edit_scene.sequence_editor.sequences: + edit_scene.frame_set(strip.frame_final_start) + assert bpy.context.window.scene == strip.scene + + +def test_keep_gp_toolsettings(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + sync_settings = get_sync_settings() + + sync_settings.keep_gpencil_tool_settings = True + + # Shot 1 + # Create GP object and change tool settings's brush type + bpy.context.window.scene = shot_strip_1.scene + bpy.ops.object.gpencil_add(type="MONKEY") + gpencil_shot_1 = bpy.context.active_object + gp_settings_1 = shot_strip_1.scene.tool_settings.gpencil_paint + gp_settings_1.brush = bpy.data.brushes["Pencil"] + + # Shot 2 + # Create a GP object and assign the same materials as the first one + shot_strip_2 = create_shot_scene(edit_scene, 1, shot_strip_1.frame_final_end) + bpy.context.window.scene = shot_strip_2.scene + bpy.ops.object.gpencil_add(type="MONKEY") + gpencil_shot_2 = bpy.context.active_object + for idx, material in enumerate(gpencil_shot_1.data.materials): + gpencil_shot_2.material_slots[idx].material = material + # Select a different brush type than in Shot 1 + gp_settings_2 = shot_strip_2.scene.tool_settings.gpencil_paint + gp_settings_2.brush = bpy.data.brushes["Pen"] + + # Shot 3 + # Empty scene + shot_strip_3 = create_shot_scene(edit_scene, 1, shot_strip_2.frame_final_end) + gp_settings_3 = shot_strip_3.scene.tool_settings.gpencil_paint + + # Start the tests from a neutral scene + bpy.context.window.scene = edit_scene + edit_scene.frame_set(0) + + # Go to Shot 1 and change the active object's material + edit_scene.frame_set(shot_strip_1.frame_final_start) + gpencil_shot_1.active_material_index = 2 + + # Go to Shot2 + edit_scene.frame_set(shot_strip_2.frame_final_start) + + # Go to Shot3 + edit_scene.frame_set(shot_strip_3.frame_final_start) + + # Check that grease pencil settings are identical for Shot 1 and 2 + assert gp_settings_2.brush == gp_settings_1.brush + assert gpencil_shot_2.active_material == gpencil_shot_1.active_material + # Check that a Shot without any GP does not break the system + assert gp_settings_3 is None + + +def test_keep_gp_toolsettings_interaction_mode(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + sync_settings = get_sync_settings() + + sync_settings.keep_gpencil_tool_settings = True + + interaction_mode = "PAINT_GPENCIL" + # Shot 1 + # Create GP object and change interaction mode + bpy.context.window.scene = shot_strip_1.scene + bpy.ops.object.gpencil_add(type="MONKEY") + gpencil_shot_1 = bpy.context.active_object + # Go to paint mode + bpy.ops.object.mode_set(mode=interaction_mode) + + # Shot 2 + # Empty scene + shot_strip_2 = create_shot_scene(edit_scene, 1, shot_strip_1.frame_final_end) + + # Shot 3 + # Scene with a single GP object + shot_strip_3 = create_shot_scene(edit_scene, 1, shot_strip_2.frame_final_end) + bpy.context.window.scene = shot_strip_3.scene + bpy.ops.object.gpencil_add(type="MONKEY") + gpencil_shot_3 = bpy.context.active_object + + # Start the tests from a neutral scene + bpy.context.window.scene = edit_scene + edit_scene.frame_set(0) + + # Go to Shot 1: active GP object in paint mode + edit_scene.frame_set(shot_strip_1.frame_final_start) + # Go to Shot 2: no active object + edit_scene.frame_set(shot_strip_2.frame_final_start) + # Go to Shot 3: active GP object initially in object mode + edit_scene.frame_set(shot_strip_3.frame_final_start) + # Ensure GP mode was applied to the one in Shot 3, despite Shot 2 being empty + assert gpencil_shot_1.mode == gpencil_shot_3.mode == interaction_mode + + +def test_bidirectional_within_shot_range(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + + edit_scene.frame_set(shot_strip_1.frame_final_start) + assert bpy.context.window.scene == shot_strip_1.scene + offset = 1 + shot_strip_1.scene.frame_set(shot_strip_1.scene.frame_current + offset) + assert edit_scene.frame_current == shot_strip_1.frame_final_start + offset + + +def test_bidirectional_outside_shot_range(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + edit_scene.frame_set(shot_strip_1.frame_final_start) + + # Go outside scene's range + shot_strip_1.scene.frame_set(shot_strip_1.scene.frame_end + 10) + # Master time should not have been updated + assert edit_scene.frame_current == shot_strip_1.frame_final_start + + +def test_bidirectional_outside_shot_range_to_surrounding_shots(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + edit_scene.frame_set(shot_strip_1.frame_final_start) + # Create another Shot right after Shot 1 + shot_strip_2 = create_shot_scene(edit_scene, 1, shot_strip_1.frame_final_end) + + # From frame start, go outside scene's range within Shot 1 by one frame + shot_strip_1.scene.frame_set(shot_strip_1.scene.frame_end + 1) + # This should not update current scene, as we did not pass trough end frame + assert bpy.context.window.scene == shot_strip_1.scene + # Now, go to the end of Shot 1 first + shot_strip_1.scene.frame_set(shot_strip_1.scene.frame_end) + # Go outside scene's range within Shot 1 by one frame + shot_strip_1.scene.frame_set(shot_strip_1.scene.frame_end + 1) + # Master time should have been update and Shot 2 should now be the active Shot + assert edit_scene.frame_current == shot_strip_2.frame_final_start + assert bpy.context.window.scene == shot_strip_2.scene + + # Go back one frame before Shot 2 frame start + shot_strip_2.scene.frame_set(shot_strip_2.scene.frame_start - 1) + # Master time should have been updated and Shot 1 should not be active again + assert edit_scene.frame_current == shot_strip_1.frame_final_end - 1 + assert bpy.context.window.scene == shot_strip_1.scene + + +def test_bidirectional_off(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + sync_settings = get_sync_settings() + sync_settings.bidirectional = False + + edit_scene.frame_set(shot_strip_1.frame_final_start) + shot_strip_1.scene.frame_set(shot_strip_1.scene.frame_current + 10) + assert edit_scene.frame_current == shot_strip_1.frame_final_start + + +def test_camera_strip(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + shot_scene = shot_strip_1.scene + + # Create 2 cameras in the shot scene + cam1_data = bpy.data.cameras.new(name="Cam1") + cam1 = bpy.data.objects.new(name="Cam1", object_data=cam1_data) + cam2_data = bpy.data.cameras.new(name="Cam2") + cam2 = bpy.data.objects.new(name="Cam2", object_data=cam2_data) + shot_scene.collection.objects.link(cam1) + shot_scene.collection.objects.link(cam2) + + # Create a new strip poiting to the same scene + shot_strip_2 = edit_scene.sequence_editor.sequences.new_scene( + name=f"{shot_scene.name}_2", + scene=shot_scene, + channel=1, + frame_start=shot_strip_1.frame_final_end, + ) + + # Define different cameras for each strip + shot_strip_1.scene_camera = cam1 + shot_strip_2.scene_camera = cam2 + + # Go to 1st strip: the scene should be using cam1 as active camera + edit_scene.frame_set(shot_strip_1.frame_final_start) + assert shot_scene.camera == shot_strip_1.scene_camera == cam1 + # Go to 2n strip: the scene should be using cam1 as active camera + edit_scene.frame_set(shot_strip_2.frame_final_start) + assert shot_scene.camera == shot_strip_2.scene_camera == cam2 + + +def test_skip_muted_strips(complex_synced_setup): + edit_scene, shots = complex_synced_setup + + # Test Strip 4 is active + edit_scene.frame_set(1) + assert bpy.context.window.scene == shots[3].scene + + # Mute Channel 4 and check if Strip 3 is active + edit_scene.sequence_editor.channels[4].mute = True + edit_scene.frame_set(2) + assert bpy.context.window.scene == shots[2].scene + + # Mute Strip 3 and check if Strip 2 is active + shots[2].mute = True + edit_scene.frame_set(3) + assert bpy.context.window.scene == shots[1].scene + + +def test_active_follows_playhead(basic_synced_setup): + edit_scene, shot_strip_1 = basic_synced_setup + + edit_scene.sequence_editor.active_strip = shot_strip_1 + + sync_settings = get_sync_settings() + sync_settings.active_follows_playhead = True + + shot_strip_2 = create_shot_scene(edit_scene, 1, shot_strip_1.frame_final_end + 1) + + edit_scene.frame_set(shot_strip_2.frame_final_start) + assert edit_scene.sequence_editor.active_strip == shot_strip_2 @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2023, The SPA Studios. All rights reserved. + +import bpy + + +def create_shot_scene( + edit_scene: bpy.types.Scene, channel: int, frame_start: int +) -> bpy.types.SceneSequence: + """ + Create a new empty scene and adds it as a strip in `edit_scene`'s sequence editor. + + :param edit_scene: The edit scene + :param channel: Shot strip's channel + :param frame_start: Shot strip's frame start + :return: The created shot strip + """ + # Create a new scene + shot_scene = bpy.data.scenes.new(name="SHOT") + # Add a scene strip in the edit scene's sequence editor + shot_strip = edit_scene.sequence_editor.sequences.new_scene( + name=shot_scene.name, + scene=shot_scene, + channel=channel, + frame_start=frame_start, + ) + return shot_strip