Source code for cgl.apps.alchemy.alchemy
# --- EARLY DLL LOAD TRACER ---
# print("Starting DLL Load Tracer...")
# import ctypes
#
# kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
#
# # Save originals
# _LoadLibraryW = kernel32.LoadLibraryW
# _LoadLibraryExW = kernel32.LoadLibraryExW
#
# _LoadLibraryW.argtypes = [ctypes.c_wchar_p]
# _LoadLibraryW.restype = ctypes.c_void_p
#
# _LoadLibraryExW.argtypes = [ctypes.c_wchar_p, ctypes.c_void_p, ctypes.c_uint32]
# _LoadLibraryExW.restype = ctypes.c_void_p
#
# def hooked_LoadLibraryW(filename):
# print(f"DLL Load: {filename}")
# return _LoadLibraryW(filename)
#
# def hooked_LoadLibraryExW(filename, file_handle, flags):
# print(f"DLL LoadEx: {filename}")
# return _LoadLibraryExW(filename, file_handle, flags)
#
# # Patch BOTH APIs so all DLL loads print
# ctypes.windll.kernel32.LoadLibraryW = hooked_LoadLibraryW
# ctypes.windll.kernel32.LoadLibraryExW = hooked_LoadLibraryExW
#
# print("DLL tracer active")
#
# import os
# import glob
#
# print("Scanning for OTIO .pyd files...")
# otio_pyds = glob.glob(os.path.join(os.path.dirname(__file__), "..", "..", "**", "_otio*.pyd"), recursive=True)
# for f in otio_pyds:
# print("Found OTIO binary:", f)
# # -----------------------------------------
#
# os.environ["OTIO_DEBUG"] = "1"
import sys
import os
if hasattr(sys, "_MEIPASS"): # running as a frozen EXE
# os.environ["OTIO_DEBUG"] = "1"
from cgl.plugins.otio.edit import load_alchemy_otio_manifests
load_alchemy_otio_manifests()
import opentimelineio as otio
# Force manifest rebuild (belt + suspenders)
manifest = otio.plugins.ActiveManifest()
print("OTIO registered adapters:")
print(sorted(a.name for a in manifest.adapters))
print("Alchemy running OTIO version:", otio.__version__)
from PySide6 import QtCore, QtGui, QtWidgets # noqa: E402
from PySide6.QtCore import QThreadPool # noqa: E402
import importlib # noqa: E402
import logging # noqa: E402
from functools import partial # noqa: E402
from importlib import reload # noqa: E402
from cgl.apps.alchemy.alchemy_startup import ( # noqa: E402
VersionCheckWorker,
init_alchemy,
)
from cgl.apps.alchemy.widgets.company_panel import CompanyPanel # noqa: E402
from cgl.apps.css_editor.alchemy_theme_editor import ThemeManager # noqa: E402
from cgl.core.config.query import ( # noqa: E402
AlchemyConfigManager,
get_fonts_root,
)
from cgl.core.path.object import PathObject # noqa: E402
from cgl.core.utils.general import load_json # noqa: E402
import cgl.apps.alchemy.widgets.asset_widget as asset_widget # noqa: E402
import cgl.apps.alchemy.widgets.path_widget as path_widget # noqa: E402
import cgl.apps.alchemy.widgets.project_panel as project_panel # noqa: E402
import cgl.apps.alchemy.widgets.version_window as version_window # noqa: E402
from cgl.core.utils.general import apply_theme # noqa: E402
import resources.resources_rc # noqa: F401, E402
CFG = AlchemyConfigManager()
reload(asset_widget)
reload(project_panel)
reload(path_widget)
reload(version_window)
[docs]
class AlchemyWidget(QtWidgets.QWidget):
"""
This is the main widget for the Alchemy application.
"""
def __init__(self, ue=False):
super(AlchemyWidget, self).__init__()
apply_theme(self)
# self.load_fonts()
self.start_task_from_existing_dialog = None
self.report_bug_dialog = None
self.request_feature_dialog = None
self.theme_manager = ThemeManager()
self.theme_manager.themeUpdated.connect(self.setStyleSheet)
self.skip_config_save = False
self.thread_pool = QThreadPool()
self.current_version = None
self.cookbook_root = None
self.open_windows = []
self.ue = ue
self.company_panel = CompanyPanel(parent=self)
if not self.ue:
import cgl.apps.alchemy.widgets.io_widget as io_widget
self.project_widget = project_panel.ProjectPanel(
parent=self, company=CFG.company, project=CFG.project, ue=self.ue
)
self.io_widget = io_widget.IOWidget(parent=self)
else:
# we'll set the UE logo with Alchemy Green
self.set_alchemy_ue_logo()
self.project_widget = project_panel.ProjectPanel(
parent=self,
company=CFG.company.lower(),
project=CFG.project.lower(),
ue=self.ue,
)
self.project_widget.setSizePolicy(
QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred
)
self.asset_widget = asset_widget.AssetWidget(ue=self.ue)
self.path_widget = path_widget.PathWidget(ue=self.ue)
CFG.path_widget = self.path_widget
self.version_widget = version_window.VersionWidget(parent=self, ue=self.ue)
main_widget = QtWidgets.QWidget(self)
# the horizontal layout for the main widget
hlayout = QtWidgets.QHBoxLayout(main_widget)
hlayout.setContentsMargins(0, 0, 0, 0)
hlayout.setSpacing(
1
) # if i want to handle borders with this, i need to set a Frame around the main widget
# The main Asset Panel
self.asset_panel = QtWidgets.QWidget(self)
asset_vlayout = QtWidgets.QVBoxLayout(self.asset_panel)
asset_vlayout.setContentsMargins(0, 0, 0, 0)
asset_vlayout.setSpacing(1)
# ---- STACK ----
self.asset_stack = QtWidgets.QStackedWidget(self)
self.asset_stack.addWidget(self.asset_widget) # index 0
self.asset_stack.addWidget(self.version_widget) # index 1
if not self.ue:
self.asset_stack.addWidget(self.io_widget)
# ---- Asset panel Layout ----
asset_vlayout.addWidget(self.asset_stack)
asset_vlayout.addWidget(self.path_widget)
hlayout.addWidget(self.company_panel)
hlayout.addWidget(self.project_widget)
hlayout.addWidget(self.asset_panel)
self.setLayout(hlayout)
## set up connections
self.project_widget.project_filter_widget.project_selection_widget.project_changed.connect(
self.set_task_info
)
self.project_widget.project_filter_widget.tree_updated.connect(
self.set_task_info
)
CFG.path_object_changed.connect(
self.project_widget.project_filter_widget.refresh
)
CFG.path_object_changed.connect(self.path_widget.update_path_text)
# CFG.path_object_changed.connect(self.update_cookbook_root)
if not self.ue:
self.version_widget.files_widget.files_group.ue_source_widget.hide()
else:
CFG.refresh_assets = True
self.company_panel.setVisible(False)
self.version_widget.files_widget.files_group.source_widget.hide()
self.version_widget.files_widget.files_group.ue_source_widget.show()
self.project_widget.project_filter_widget.item_clicked.connect(
self.edit_project_tags
)
self.asset_widget.thumbnail_grid.show_files.connect(self.on_thumbnail_clicked)
# load the tasks for the initial project
self.version_widget.path_bar.back_clicked.connect(self.on_back_clicked)
self.version_widget.files_widget.files_group.source_widget.fileSelected.connect(
self.path_widget.update_path_text
)
self.version_widget.files_widget.files_group.render_widget.fileSelected.connect(
self.path_widget.update_path_text
)
self.version_widget.files_widget.files_group.source_widget.fileSelected.connect(
self.version_widget.notes_widget.thumbnail_widget.display_system_thumbnail
)
self.version_widget.versions_widget.update_files.connect(
self.path_widget.update_path_text
)
self.company_panel.setCompanies()
self.company_panel.joinCompany.connect(self.join_company)
self.check_for_updates()
self.load_tasks()
# self.ingest_widget.hide()
[docs]
def set_alchemy_ue_logo(self):
"""
Sets the Alchemy UE-specific icon when running inside Unreal Engine.
"""
import cgl.ui.widgets.widgets as wdg
from importlib import reload
reload(wdg)
# Retrieve Unreal-themed icon (you already have this file)
ue_logo = CFG.get_icon_path("svg", "unreal.svg")
print("------>>>", ue_logo)
# Alchemy brand green (consistent across UI themes)
color = "#78C2A4"
# Build tinted icon
icon = wdg.svg_icon(ue_logo, color)
# Apply to widget
self.setWindowIcon(icon)
# Optional: Make the title reflect the context
self.setWindowTitle("Alchemy for Unreal Engine")
[docs]
def update_project_msd_from_sg(self):
"""
Updates the project metadata from shotgun
Returns:
"""
sg = True
company = CFG.company
project = CFG.project
if project:
if sg:
try:
import cookbook.shotgrid.create as custom_sg_create
custom_sg_create.project_msd(company, project)
except ModuleNotFoundError:
logging.warning("Cookbook override not found, using default")
import cgl.plugins.shotgrid.create
cgl.plugins.shotgrid.create.project_msd(company, project)
logging.info("project MSD updated")
# self.load_tasks()
else:
logging.info("Updating MSD based off files on disk")
else:
logging.warning("No Project Selected")
# refresh the project widget with the new data
self.load_tasks()
[docs]
def load_tasks(self):
company = CFG.company
project = CFG.project
print("Loading tasks for company:", company, "and project:", project)
if not project and not company:
return None
# đźš« Don't proceed if no compan
if not company:
logging.warning("No company set. Skipping load_tasks.")
return
self.company_panel.select_company(company)
if project and company:
po_dict = {
"root": CFG.prod_root,
"project": project,
"company": company,
"sync_root": "VERSIONS",
"season": "1",
}
po = PathObject().from_dict(po_dict)
CFG.set_path_object(po)
self.set_task_info()
self.path_widget.update_path_text()
else:
# Avoid excessive empty task reloads
logging.warning("No project selected. Doing Nothing.")
# po_dict = {"project": "", "company": company, "sync_root": "VERSIONS"}
# po = PathObject().from_dict(po_dict)
# self.po_changed.emit(po)
# self.project_changed.emit([])
[docs]
def join_company(self):
"""
This is a slot that is called when the user joins a company.
It sets the path object to the company path object.
Args:
company: The company to join.
"""
from cgl.apps.alchemy.widgets.share import JoinDialog
dialog = JoinDialog()
dialog.exec()
# def on_project_changed(self):
# sender = self.sender()
# project_long_name = sender.currentText()
# project_short_name = sender.project_dict["long_to_short"][project_long_name]
# po_dict = {
# "project": project_short_name,
# "company": sender.company,
# "sync_root": "VERSIONS",
# }
# po = PathObject().from_dict(po_dict)
# CFG.set_path_object(po)
[docs]
def check_for_updates(self):
worker = VersionCheckWorker()
worker.signals.finished.connect(self.handle_version_result)
self.thread_pool.start(worker)
[docs]
def handle_version_result(self, latest_version, current_version):
# logging.info("Current version:", current_version)
# set the window tilte to the current version
self.setWindowTitle(f"Alchemy - {current_version}")
self.current_version = current_version
if self.current_version == "dev":
self.setWindowTitle(
f"Alchemy - {current_version} ({latest_version} available)"
)
logging.info("Skipping update check for dev version.")
return
if latest_version != self.current_version:
self.setWindowTitle(
f"Alchemy v{current_version} (v{latest_version} available)"
)
self.show_update_notice(latest_version)
[docs]
def show_update_notice(self, latest_version):
return
result = QtWidgets.QMessageBox.information(
self,
"Update Available",
f"A new version of Alchemy is available:\n\n{latest_version}",
QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel,
)
if result == QtWidgets.QMessageBox.Ok:
self.run_update() # replace with your update logic
[docs]
def run_update(self):
"""
Launches the Alchemy installer from _internal and exits the application.
"""
import os
import subprocess
import sys
if hasattr(sys, "_MEIPASS"):
installer_path = os.path.join(sys._MEIPASS, "bin", "alchemy_installer.exe")
else:
logging.info("Running in development mode, using local installer path.")
return
if not os.path.exists(installer_path):
QtWidgets.QMessageBox.critical(
self,
"Installer Not Found",
f"❌ Unable to find the Alchemy installer at:\n{installer_path}",
)
return
try:
# Launch installer with admin privileges via PowerShell
subprocess.Popen(
[
"powershell",
"-Command",
f"Start-Process -FilePath '{installer_path}' -Verb runAs",
]
)
logging.info(f"🚀 Running installer: {installer_path}")
except Exception as e:
QtWidgets.QMessageBox.critical(
self, "Update Failed", f"❌ Failed to launch installer:\n{e}"
)
return
# Gracefully close the app
self.force_close()
[docs]
def load_fonts(self):
font_db = QtGui.QFontDatabase()
font_directory = get_fonts_root()
# Loop over all files in the font directory
for font_file in os.listdir(font_directory):
if font_file.endswith(".ttf") or font_file.endswith(".otf"):
font_path = os.path.join(font_directory, font_file)
font_id = font_db.addApplicationFont(font_path)
if font_id == -1:
logging.info(f"Failed to load font: {font_file}")
else:
logging.info(f"Font loaded: {font_file}")
[docs]
def update_path_display(self, path_object):
"""
This updates the path display for the path widget.
Args:
path_object:
Returns:
"""
self.path_widget.update_path_display(path_object)
[docs]
def on_thumbnail_clicked(self):
"""
This is a slot that is called when a thumbnail is clicked.
"""
from cgl.plugins.alchemy.create import start_task
logging.info("thumbnail clicked, starting task")
print("ue value", self.ue)
if self.ue:
print("we're in unreal engine")
path_object = CFG.path_object
# launch the start recipe, for this we want the regular path_object.
new_folder = start_task(path_object, software="unreal")
if new_folder:
self.show_versions_screen()
return
path_object = CFG.path_object
version_path = path_object.get_path()
# we have versions of this task - show the versions screen
if os.path.exists(version_path):
self.show_versions_widget()
else:
# Start a task from an existing publish/user version.
users = path_object.get_users()
if users and path_object.task != "tex": # skip this on substance.
from cgl.ui.tools.new_task_from_existing.main import (
StartTaskWithOtherUsers,
)
self.start_task_from_existing_dialog = StartTaskWithOtherUsers(
parent=None, users=users, path_object=path_object
)
self.start_task_from_existing_dialog.show()
self.start_task_from_existing_dialog.raise_() # <-- important
logging.info("Launching Widget for starting task from other users")
new_folder = True # TODO - need to swithc this to proper signals coming from the dialog itself so that if we cancel we aren't creating folders.
# only show this when triggered to show it from the UI.
self.start_task_from_existing_dialog.task_started.connect(
self.show_versions_widget
)
# start a task the normal way.
else:
# We're starting a fresh task here.
new_folder = start_task(path_object, software="alchemy")
self.show_versions_widget()
[docs]
def show_versions_widget(self):
self.show_versions_screen()
self.version_widget.versions_widget.version_combo_box.setCurrentIndex(0)
self.version_widget.versions_widget.refresh_clicked()
[docs]
def on_back_clicked(self):
"""
This is a slot that is called when the back button is clicked.
"""
# clean out the path_object
logging.info("back clicked, did i set the path correctly?")
self.show_main_screen()
[docs]
def edit_project_tags(self, project_filter):
"""
This is a slot that is called when the project asset/shot filters are selected.
It sets the tags for the asset widget.
Args:
data:
Returns:
"""
if not self.project_widget.project_filter_widget.tree_widget.selectedItems():
return
selected_item = (
self.project_widget.project_filter_widget.tree_widget.selectedItems()[0]
)
if selected_item.path_variable == "ingests":
from pathlib import Path
# show the io widget instead of the asset widget
IO_po = CFG.path_object.copy(sync_root="IO")
io_path = Path(IO_po.get_path())
io_path = io_path / "ingest" / project_filter
self.asset_stack.setCurrentIndex(2)
self.io_widget.ingest_tab.load_folder(io_path.as_posix())
return
self.asset_widget.show()
tag = project_filter
self.asset_widget.asset_filter_widget.remove_all_tags()
if tag:
self.asset_widget.asset_filter_widget.add_tag(tag)
[docs]
def edit_task_tags(self, task_filters):
"""
This is a slot that is called when the radio buttons are selected.
It sets the tags for the asset widget.
Args:
data:
Returns:
"""
self.clean_tasks_from_filter()
for task in task_filters:
self.asset_widget.asset_filter_widget.add_tag(task)
[docs]
def clean_tasks_from_filter(self):
"""
This removes anything in the "all_tasks" list from the task_filters list.
Returns:
"""
all_tasks = self.project_widget.task_widget.all_tasks
current_filters = (
self.asset_widget.asset_filter_widget.asset_filter_widget.frame.get_tags()
)
# remove anything in all tasks from the task_filters
for task in all_tasks:
if task in current_filters:
self.asset_widget.asset_filter_widget.remove_tag(task)
[docs]
def set_task_info(self):
"""
This sets the task info for the asset widget
Args:
data:
Returns:
"""
self.asset_widget.thumbnail_grid.get_current_filters()
project_msd_path = CFG.path_object.get_project_msd_path()
if not os.path.exists(project_msd_path):
logging.warning(
f"[set_task_info] Project MSD path does not exist, no task info to set. {project_msd_path}"
)
# we want to hide the asset widget if there is no project MSD path.
self.asset_widget.hide()
return
if CFG.path_object and os.path.exists(project_msd_path):
print("updating grid")
self.asset_widget.thumbnail_grid.load_grid()
else:
logging.warning("[set_task_info] No path object set, cannot set task info.")
[docs]
class AlchemyMainWindow(QtWidgets.QMainWindow):
def __init__(self):
super(AlchemyMainWindow, self).__init__()
apply_theme(self)
self.menu_bar = self.menuBar()
self.setWindowTitle("Alchemy")
self.setCentralWidget(AlchemyWidget())
self.project_selection_widget = (
self.centralWidget().project_widget.project_filter_widget.project_selection_widget.project_selector
)
self.windows = []
self.build_core_menus()
print(1234, CFG.company, CFG.project, CFG.get_cookbook_path())
self.get_cookbook_menus()
self.create_cookbook_menus()
self.resize(1260, 740) # or whatever you want
self.setFixedSize(self.size()) # prevents user resize
self.icon = QtGui.QIcon(":/icons/Alchemy.iconset/icon_16x16@2x.png")
self.setWindowIcon(self.icon)
company_panel = self.centralWidget().company_panel
company_panel.companyActivated.connect(self.close)
company_panel.createCompany.connect(
lambda: self.statusBar().showMessage("Create Company…", 3000)
)
company_panel.joinCompany.connect(
lambda: self.statusBar().showMessage("Join Company…", 3000)
)
company_panel.companySettings.connect(
lambda c: self.statusBar().showMessage(f"Settings for {c.name}", 3000)
)
company_panel.companyInvite.connect(
lambda c: self.statusBar().showMessage(f"Invite for {c.name}", 3000)
)
company_panel.companyEditLogo.connect(
lambda c: self.statusBar().showMessage(f"Edit Logo for {c.name}", 3000)
)
CFG.path_object_changed.connect(
lambda po: self.statusBar().showMessage(
f"Path Object Changed: {po.get_path()}", 3000
)
)
# menu.aboutToShow.connect(partial(self.populate_menu, menu))
[docs]
def refresh_current_project(self):
"""
Refreshes the current project by reloading the project widget.
This is useful if the project has changed or needs to be reloaded.
"""
self.project_selection_widget.reload_alchemy_config()
[docs]
def closeEvent(self, event):
if not getattr(self, "skip_config_save", False):
logging.info(1)
# self.centralWidget().header_widget.close()
else:
logging.info(2)
# self.skip_config_save = False # Reset for next time
event.accept()
[docs]
def force_close(self):
"""
This is a method to force close the alchemy widget.
It will close all the open windows and reset the path object.
Args:
company: The company to reset the path object to.
"""
# self.skip_config_save = True
self.close()
[docs]
def main():
import sys
if not init_alchemy():
return
from cgl.core.utils.general import apply_theme
# âś… Add this early exit for CI testing
if "--test" in sys.argv:
logging.info("TEST MODE: Alchemy test mode — minimal init successful.")
sys.exit(0)
# add the python path for the cgl folder to PATH
root_path = CFG.get_code_root()
if root_path not in sys.path:
sys.path.append(root_path)
# sync_ui.main()
init_alchemy()
# Set before creating the app (and before any QStyle/theme init)
QtCore.QCoreApplication.setOrganizationName("CGLumberjack")
QtCore.QCoreApplication.setOrganizationDomain(
"alchemystudio.com"
) # optional but nice on macOS
QtCore.QCoreApplication.setApplicationName("Alchemy")
QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_DontShowIconsInMenus, True)
app = QtWidgets.QApplication(sys.argv)
apply_theme(app)
window = AlchemyMainWindow()
window.setWindowTitle("Alchemy")
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
try:
main()
except RuntimeError as e:
print("Operation Finished - Restart Alchemy", e)