← sequencer-addon

Initial commit

c9655fb2c835d4cefecbbc41ddaff34a92b7e9cc · Yann Lanthony · Jan 12, 2023 17:28

+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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..02ab6ed
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e722690
--- /dev/null
+++ b/CHANGELOG.md
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..68072a4
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -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.
diff --git a/COPYING.md b/COPYING.md
new file mode 100644
index 0000000..d7c207a
--- /dev/null
+++ b/COPYING.md
@@ -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
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2098f68
--- /dev/null
+++ b/README.md
@@ -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)).
diff --git a/apidoc/conf.py b/apidoc/conf.py
new file mode 100644
index 0000000..1688a95
--- /dev/null
+++ b/apidoc/conf.py
@@ -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
diff --git a/apidoc/index.rst b/apidoc/index.rst
new file mode 100644
index 0000000..561d177
--- /dev/null
+++ b/apidoc/index.rst
@@ -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`
diff --git a/requirements.apidoc.txt b/requirements.apidoc.txt
new file mode 100644
index 0000000..c336d5f
--- /dev/null
+++ b/requirements.apidoc.txt
@@ -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
diff --git a/requirements.dev.txt b/requirements.dev.txt
new file mode 100644
index 0000000..0283e65
--- /dev/null
+++ b/requirements.dev.txt
@@ -0,0 +1,3 @@
+fake-bpy-module-3.1
+flake8==3.7.9
+black==19.10b0
\ No newline at end of file
diff --git a/requirements.tests.txt b/requirements.tests.txt
new file mode 100644
index 0000000..d1ee445
--- /dev/null
+++ b/requirements.tests.txt
@@ -0,0 +1,2 @@
+pytest==7.1.2
+mypy==0.950
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d0b89d9
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+OpenTimelineIO==0.15.0.1
\ No newline at end of file
diff --git a/scripts/run_mypy.py b/scripts/run_mypy.py
new file mode 100644
index 0000000..76a3e3c
--- /dev/null
+++ b/scripts/run_mypy.py
@@ -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())
diff --git a/scripts/run_pytest.py b/scripts/run_pytest.py
new file mode 100644
index 0000000..7db011a
--- /dev/null
+++ b/scripts/run_pytest.py
@@ -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))
diff --git a/scripts/run_sphinx-build.py b/scripts/run_sphinx-build.py
new file mode 100644
index 0000000..2d252cc
--- /dev/null
+++ b/scripts/run_sphinx-build.py
@@ -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())
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..957ed02
--- /dev/null
+++ b/setup.cfg
@@ -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
diff --git a/spa_sequencer/__init__.py b/spa_sequencer/__init__.py
new file mode 100644
index 0000000..d4216f9
--- /dev/null
+++ b/spa_sequencer/__init__.py
@@ -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()
diff --git a/spa_sequencer/editorial/__init__.py b/spa_sequencer/editorial/__init__.py
new file mode 100644
index 0000000..e9b42e5
--- /dev/null
+++ b/spa_sequencer/editorial/__init__.py
@@ -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()
diff --git a/spa_sequencer/editorial/core.py b/spa_sequencer/editorial/core.py
new file mode 100644
index 0000000..74830b2
--- /dev/null
+++ b/spa_sequencer/editorial/core.py
@@ -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
diff --git a/spa_sequencer/editorial/ops.py b/spa_sequencer/editorial/ops.py
new file mode 100644
index 0000000..1529c11
--- /dev/null
+++ b/spa_sequencer/editorial/ops.py
@@ -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)
diff --git a/spa_sequencer/editorial/ui.py b/spa_sequencer/editorial/ui.py
new file mode 100644
index 0000000..8c6fb2a
--- /dev/null
+++ b/spa_sequencer/editorial/ui.py
@@ -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)
diff --git a/spa_sequencer/editorial/vse_io/__init__.py b/spa_sequencer/editorial/vse_io/__init__.py
new file mode 100644
index 0000000..6550fa2
--- /dev/null
+++ b/spa_sequencer/editorial/vse_io/__init__.py
@@ -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()
diff --git a/spa_sequencer/editorial/vse_io/core.py b/spa_sequencer/editorial/vse_io/core.py
new file mode 100644
index 0000000..d888873
--- /dev/null
+++ b/spa_sequencer/editorial/vse_io/core.py
@@ -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
diff --git a/spa_sequencer/editorial/vse_io/ops.py b/spa_sequencer/editorial/vse_io/ops.py
new file mode 100644
index 0000000..5016602
--- /dev/null
+++ b/spa_sequencer/editorial/vse_io/ops.py
@@ -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)
diff --git a/spa_sequencer/editorial/vse_io/ui.py b/spa_sequencer/editorial/vse_io/ui.py
new file mode 100644
index 0000000..0c262ee
--- /dev/null
+++ b/spa_sequencer/editorial/vse_io/ui.py
@@ -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)
diff --git a/spa_sequencer/gpu_utils.py b/spa_sequencer/gpu_utils.py
new file mode 100644
index 0000000..3122554
--- /dev/null
+++ b/spa_sequencer/gpu_utils.py
@@ -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)
diff --git a/spa_sequencer/keymaps.py b/spa_sequencer/keymaps.py
new file mode 100644
index 0000000..fa705a4
--- /dev/null
+++ b/spa_sequencer/keymaps.py
@@ -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()
diff --git a/spa_sequencer/preferences.py b/spa_sequencer/preferences.py
new file mode 100644
index 0000000..21e32ea
--- /dev/null
+++ b/spa_sequencer/preferences.py
@@ -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)
diff --git a/spa_sequencer/render/__init__.py b/spa_sequencer/render/__init__.py
new file mode 100644
index 0000000..18f1d78
--- /dev/null
+++ b/spa_sequencer/render/__init__.py
@@ -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()
diff --git a/spa_sequencer/render/ops.py b/spa_sequencer/render/ops.py
new file mode 100644
index 0000000..cbc172f
--- /dev/null
+++ b/spa_sequencer/render/ops.py
@@ -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)
diff --git a/spa_sequencer/render/props.py b/spa_sequencer/render/props.py
new file mode 100644
index 0000000..208d0b5
--- /dev/null
+++ b/spa_sequencer/render/props.py
@@ -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
diff --git a/spa_sequencer/render/tasks.py b/spa_sequencer/render/tasks.py
new file mode 100644
index 0000000..73905ab
--- /dev/null
+++ b/spa_sequencer/render/tasks.py
@@ -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
diff --git a/spa_sequencer/render/ui.py b/spa_sequencer/render/ui.py
new file mode 100644
index 0000000..8c75300
--- /dev/null
+++ b/spa_sequencer/render/ui.py
@@ -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)
diff --git a/spa_sequencer/sequence/__init__.py b/spa_sequencer/sequence/__init__.py
new file mode 100644
index 0000000..468a2b9
--- /dev/null
+++ b/spa_sequencer/sequence/__init__.py
@@ -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()
diff --git a/spa_sequencer/sequence/ops.py b/spa_sequencer/sequence/ops.py
new file mode 100644
index 0000000..ed41b38
--- /dev/null
+++ b/spa_sequencer/sequence/ops.py
@@ -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)
diff --git a/spa_sequencer/sequence/overlay.py b/spa_sequencer/sequence/overlay.py
new file mode 100644
index 0000000..5bcade9
--- /dev/null
+++ b/spa_sequencer/sequence/overlay.py
@@ -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)
diff --git a/spa_sequencer/sequence/props.py b/spa_sequencer/sequence/props.py
new file mode 100644
index 0000000..e0cb884
--- /dev/null
+++ b/spa_sequencer/sequence/props.py
@@ -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)
diff --git a/spa_sequencer/sequence/ui.py b/spa_sequencer/sequence/ui.py
new file mode 100644
index 0000000..b8d736c
--- /dev/null
+++ b/spa_sequencer/sequence/ui.py
@@ -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)
diff --git a/spa_sequencer/shared_folders/__init__.py b/spa_sequencer/shared_folders/__init__.py
new file mode 100644
index 0000000..bd5717a
--- /dev/null
+++ b/spa_sequencer/shared_folders/__init__.py
@@ -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()
diff --git a/spa_sequencer/shared_folders/core.py b/spa_sequencer/shared_folders/core.py
new file mode 100644
index 0000000..9a4fba3
--- /dev/null
+++ b/spa_sequencer/shared_folders/core.py
@@ -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
diff --git a/spa_sequencer/shared_folders/ops.py b/spa_sequencer/shared_folders/ops.py
new file mode 100644
index 0000000..5963ae8
--- /dev/null
+++ b/spa_sequencer/shared_folders/ops.py
@@ -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)
diff --git a/spa_sequencer/shared_folders/ui.py b/spa_sequencer/shared_folders/ui.py
new file mode 100644
index 0000000..dc11816
--- /dev/null
+++ b/spa_sequencer/shared_folders/ui.py
@@ -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)
diff --git a/spa_sequencer/shot/__init__.py b/spa_sequencer/shot/__init__.py
new file mode 100644
index 0000000..dfc0b9e
--- /dev/null
+++ b/spa_sequencer/shot/__init__.py
@@ -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()
diff --git a/spa_sequencer/shot/core.py b/spa_sequencer/shot/core.py
new file mode 100644
index 0000000..3d40369
--- /dev/null
+++ b/spa_sequencer/shot/core.py
@@ -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
diff --git a/spa_sequencer/shot/naming.py b/spa_sequencer/shot/naming.py
new file mode 100644
index 0000000..3523e4f
--- /dev/null
+++ b/spa_sequencer/shot/naming.py
@@ -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)
diff --git a/spa_sequencer/shot/ops.py b/spa_sequencer/shot/ops.py
new file mode 100644
index 0000000..57b5b1d
--- /dev/null
+++ b/spa_sequencer/shot/ops.py
@@ -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)
diff --git a/spa_sequencer/shot/ui.py b/spa_sequencer/shot/ui.py
new file mode 100644
index 0000000..96ef345
--- /dev/null
+++ b/spa_sequencer/shot/ui.py
@@ -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)
diff --git a/spa_sequencer/sync/__init__.py b/spa_sequencer/sync/__init__.py
new file mode 100644
index 0000000..d2656b4
--- /dev/null
+++ b/spa_sequencer/sync/__init__.py
@@ -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()
diff --git a/spa_sequencer/sync/core.py b/spa_sequencer/sync/core.py
new file mode 100644
index 0000000..5e3a712
--- /dev/null
+++ b/spa_sequencer/sync/core.py
@@ -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)
diff --git a/spa_sequencer/sync/ops.py b/spa_sequencer/sync/ops.py
new file mode 100644
index 0000000..922910e
--- /dev/null
+++ b/spa_sequencer/sync/ops.py
@@ -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)
diff --git a/spa_sequencer/sync/ui.py b/spa_sequencer/sync/ui.py
new file mode 100644
index 0000000..2cfbf4a
--- /dev/null
+++ b/spa_sequencer/sync/ui.py
@@ -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)
diff --git a/spa_sequencer/utils.py b/spa_sequencer/utils.py
new file mode 100644
index 0000000..439fa0a
--- /dev/null
+++ b/spa_sequencer/utils.py
@@ -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__)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..06c1d43
--- /dev/null
+++ b/tests/conftest.py
@@ -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
diff --git a/tests/test_addon.py b/tests/test_addon.py
new file mode 100644
index 0000000..d825b5d
--- /dev/null
+++ b/tests/test_addon.py
@@ -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]
diff --git a/tests/test_shared_folders.py b/tests/test_shared_folders.py
new file mode 100644
index 0000000..760e164
--- /dev/null
+++ b/tests/test_shared_folders.py
@@ -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)
diff --git a/tests/test_shot.py b/tests/test_shot.py
new file mode 100644
index 0000000..f5cccf1
--- /dev/null
+++ b/tests/test_shot.py
@@ -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
diff --git a/tests/test_shot_naming.py b/tests/test_shot_naming.py
new file mode 100644
index 0000000..8618a98
--- /dev/null
+++ b/tests/test_shot_naming.py
@@ -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"
diff --git a/tests/test_sync.py b/tests/test_sync.py
new file mode 100644
index 0000000..01e0486
--- /dev/null
+++ b/tests/test_sync.py
@@ -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
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..9814452
--- /dev/null
+++ b/tests/utils.py
@@ -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