diff --git a/README.md b/README.md index cadd830a..ba208aef 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Photoshop Integration +## For information about this fork, Refer [Here](./client/ayon_photoshop/api/extension_uxp_develop/README.md) + ### Implemented features - publishing workfile diff --git a/client/ayon_photoshop/api/com.ayon.photoshop_PS.ccx b/client/ayon_photoshop/api/com.ayon.photoshop_PS.ccx new file mode 100644 index 00000000..d0f421c6 Binary files /dev/null and b/client/ayon_photoshop/api/com.ayon.photoshop_PS.ccx differ diff --git a/extension_uxp/.gitignore b/extension_uxp/.gitignore new file mode 100644 index 00000000..2309cc8d --- /dev/null +++ b/extension_uxp/.gitignore @@ -0,0 +1,138 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + diff --git a/extension_uxp/README.md b/extension_uxp/README.md new file mode 100644 index 00000000..172dc53b --- /dev/null +++ b/extension_uxp/README.md @@ -0,0 +1,62 @@ +# uxp-ayon-photoshop + +Migration of AYON CEP to UXP! + +It's a new way to do things.
+Developed at Submarine, by Sas van Gulik, as commissioned by Thierry Paalman,
+in order to let M-series Macs participate in projects without having to work off pipe due to CEP not being allowed on these platforms, and CEP being marked as deprecated. + +This fork is meant to open discussion on migrating or offering a CEP / UXP hybrid. + +This folder contains everything you need to start developing with the JS stubs provided by Adobe. + +Make sure that this directory (extension_uxp_develop) is your current directory, and +simply run: + +`npm install` + +to download the packages present in the `package.json`. + +To use the plugin, you need to bundle it or "inject it" into photoshop while it's running. + +First of all, check which version ayon_photoshop expects to be in the manifest. +This is the version that is found in `ayon_photoshop/api/extension/CSXS/manifest.xml`,
+and you would be looking for ExtensionBundleVersion. As of writing this, 0.4.4+dev, +the expected version is `1.1.11`. The bundled .ccx package has a baked in manifest version for 1.1.1 for example. + +Then you can adjust the manifest.json in `extension_uxp/manifest.json` to reflect +this same version. + +Use the UXP developer tool (available in Adobe Creative Cloud desktop app) to develop or bundle this. +[Docs on UXP Developer tools](https://developer.adobe.com/photoshop/uxp/2022/guides/devtool/) + +Using this tool you can either bundle the app after opening the folder with the manifest.json in it, +or you can `Load & Watch` it while you are running photoshop from Ayon or anywhere else. + +If you bundle a .ccx, you can usually double click it to add it to your bundles, +and it will automatically load on startup. + +### Note: This plugin is hardcoded to use a specific websocket port/adress: +The adress we use is: `ws://localhost:8101/ws/` + +Modify Line 45 in `client/ayon_photoshop/api/webserver.py` + +```py +# in class WebServerTool, __init__(... +websocket_url = "ws://localhost:8101/ws/" # Line 45 original: os.getenv("WEBSOCKET_URL") +``` + +This is to cause no collision with the existing CEP plugin, but also because
+**UXP Plugins can NOT read environment variables.** + +This, next to the need to EXPLICITLY allow `ws://localhost:8101` in the manifest allowed domains
+('all' is not enough since that only counts for http / https connections),
+makes it far less viable to dynamically inject a port number. + +When using the UXP plugin, this should be taken into account. + +### Further setup documentation can be found in the NOTES: [Here!](./ayon_uxp/NOTES.MD) + +(It's quite complicated and different so I would recommend it.) + +Much love, Sas \ No newline at end of file diff --git a/extension_uxp/ayon_uxp/LICENSE b/extension_uxp/ayon_uxp/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/extension_uxp/ayon_uxp/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/extension_uxp/ayon_uxp/NOTES.MD b/extension_uxp/ayon_uxp/NOTES.MD new file mode 100644 index 00000000..93e509cb --- /dev/null +++ b/extension_uxp/ayon_uxp/NOTES.MD @@ -0,0 +1,115 @@ +AYON Photoshop UXP Plugin notes +--- + +### Back to [UXP Readme.](../README.md) + +Instead of relying on CSInterface.js, we rely on the internal `const uxp = require("uxp")` library.
+Note, that we are using CommonJS style import/export. + +That means: + +```js +// importing through structured binding from a 'require' statement +const {module1, module2} = require("photoshop"); + +// exporting through writing to a 'module.exports' object + +/** + * @param {number} my_number + */ +async function my_glorious_function(my_number) { + return await crunch(number); +} + +module.exports = { + my_glorious_function, + ... +} +``` + +And generally a preference to keep things async. +Some operations that mutate state definitely need to "lock up" the program,
+and for this I've written a helper function. + +```js +// Can be found in client_api.js + +const {app, core} = require("photoshop") +// Helper to execute "Modal", Blocking +// Wrapper for functions that need to write. +/** + * @param {function} func + * @param {string} commandName + */ +async function execAsModal(func, commandName) { + return await core.executeAsModal(async () => { + return await func(); + }, {"commandName": commandName}) +} +``` + +Be sure to make your command name unique! + +One special thing about photoshop's handling of files in certain operations, is that in
+certain `batchPlay` operations (New way of executing 'Action' macros in JS in UXP / Photoshop),
+The pathname must first be made into a file entry and then opened into a with a session token. + +Autocomplete will not show you this function, but it's documented [Here.](https://developer.adobe.com/photoshop/uxp/2022/uxp-api/reference-js/Modules/uxp/Persistent%20File%20Storage/FileSystemProvider/#createsessiontokenentry) + +Another quirk of UXP, is that it's not able to arbitrarily read files that aren't packaged anymore. +Therefore we can't go and read stuff from our filesystem, in other textfiles that werent present +in the folder that is bundled. + +Yet another quirk: **UXP Plugins can NOT read environment variables.** +This has led me to have to decide that this plugin will need to communicate over a fixed address. + +The easiest way to get it to talk to the plugin for testing is to go to +`client/ayon_photoshop/api/webserver.py`, line 45. + +```py +class WebServerTool: + """ + Basic POC implementation of asychronic websocket RPC server. + Uses class in external_app_1.py to mimic implementation for single + external application. + 'test_client' folder contains two test implementations of client + """ + _instance = None + + def __init__(self): + WebServerTool._instance = self + + self.client = None + self.handlers = {} + self.on_stop_callbacks = [] + + port = None + host_name = "localhost" + websocket_url = "ws://localhost:8101/ws/" # Line 45 original: os.getenv("WEBSOCKET_URL") +``` + +If you want to change the port, please change it in the modified
+`client/ayon_photoshop/api/webserver.py` like above,
+the `manifest.json` 'requiredPermissions' key -> +```js +"requiredPermissions": { + "network": { + "domains": [ + "all", + "ws://localhost:8101" // <- Modify 8101 here to allow port through. + ] + }, + "localFileSystem": "fullAccess" + } +``` + +and in the plugin itself, in `ayon-photoshop/extension_uxp/main.js` + +```js +const get_RPC = require("./client_RPC").get_RPC +const setup_rpc = require("./client_RPC").setup_rpc + +const WS_URL = "ws://localhost:8101/ws/"; // <- Modify 8101 here to allow port through. + +let RPC = null; +``` diff --git a/extension_uxp/ayon_uxp/client_RPC.js b/extension_uxp/ayon_uxp/client_RPC.js new file mode 100644 index 00000000..f48df5ec --- /dev/null +++ b/extension_uxp/ayon_uxp/client_RPC.js @@ -0,0 +1,264 @@ +const api = require("./client_api"); +const WSRPC = require("./lib/wsrpc"); + +let RPC = null; + +async function get_RPC() { + return RPC; +} + +async function setup_rpc(websocket_url) { + if (websocket_url) + console.log("websocket_url", websocket_url); + + const default_url = 'ws://localhost:8101/ws/'; + + if (websocket_url == ''){ + websocket_url = default_url; + } + + RPC = new WSRPC(websocket_url); // spin connection + console.log("connecting to:", websocket_url, RPC); + try{ + RPC.connect(); + } catch (err) { + console.log(err) + } + // await RPC.onEvent("onconnect"); + console.log("Connected!"); + + RPC.addRoute('Photoshop.open', async (data) => { + console.log('Server called client route "open":', data.path); + const result = await api.fileOpen(data.path); + console.log("open:", result); + return result + } + ) + + RPC.addRoute('Photoshop.read', async (data) => { + console.log('Server called client route "read":', data); + const result = await api.getHeadline(); + console.log("read:", result.replace("\n","")); + return result + } + ); + + RPC.addRoute('Photoshop.get_layers', async (data) => { + console.log('Server called client route "get_layers":', data); + const result = await api.getLayers(); + console.log("getLayers:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.set_visible', async (data) => { + console.log('Server called client route "set_visible":', data); + const result = await api.setVisible(data.layer_id, data.visibility); + console.log("setVisible:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.set_layers_visibility', async (data) => { + console.log('Server called client route "set_layers_visibility":', data); + const vismap = JSON.parse(data.visibility_map) + for (const [layer_id, visibility] of Object.entries(vismap)) { + console.log("setting visibility of", `layer: ${layer_id} to ${visibility}`); + const result = await api.setVisible(parseInt(layer_id), visibility); + console.log("(set_layers_visibility) setVisible:", result); + } + return null; + } + ); + + RPC.addRoute('Photoshop.get_active_document_name', async (data) => { + console.log('Server called client route "get_active_document_name":', data); + const result = await api.getActiveDocumentName(); + console.log("getActiveDocumentName:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.get_active_document_full_name', async (data) => { + console.log('Server called client route "get_active_document_full_name":', data); + const result = await api.getActiveDocumentFullName(); + console.log("getActiveDocumentFullName:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.save', async (data) => { + console.log('Server called client route "save":', data); + const result = await api.save(); + console.log("save:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.get_selected_layers', async (data) => { + console.log('Server called client route "get_selected_layers":', data); + const result = await api.getSelectedLayers(); + console.log("getSelectedLayers:", result); + return result; // client expects JSON string. + } + ); + + RPC.addRoute('Photoshop.create_group', async (data) => { + console.log('Server called client route "create_group":', data); + const result = await api.createGroup(data.name); + console.log("createGroup:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.group_selected_layers', async (data) => { + console.log('Server called client route "group_selected_layers":', data); + const result = await api.groupSelectedLayers(null, data.name); + console.log("groupSelectedLayers:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.merge_all_layersets', async (data) => { + console.log('Server called client route "merge_all_layersets":', data); + const result = await api.mergeAllLayerSets(data.parent_set); + console.log("mergeAllLayerSets:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.dissolve_layerset', async (data) => { + console.log('Server called client route "dissolve_layerset":', data); + const result = await api.dissolveLayerSet(data.layerset_id); + console.log("dissolveLayerSet:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.import_smart_object', async (data) => { + console.log('Server called client route "import_smart_object":', data); + const result = await api.importSmartObject(data.path, data.name, data.as_reference); + console.log("importSmartObject:", result); + return result + } + ); + + RPC.addRoute('Photoshop.replace_smart_object', async (data) => { + console.log('Server called client route "replace_smart_object":', data); + const result = await api.replaceSmartObjects(data.layer_id, data.path, data.name); + console.log("replaceSmartObjects:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.delete_layer', async (data) => { + console.log('Server called client route "delete_layer":', data); + const result = await api.deleteLayer(data.layer_id); + console.log("deleteLayer:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.rename_layer', async (data) => { + console.log('Server called client route "rename_layer":', data); + const result = await api.renameLayer(data.layer_id, data.name); + console.log("renameLayer:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.select_layers', async (data) => { + console.log('Server called client route "select_layers":', data); + const result = await api.selectLayers(data.layers); + console.log("selectLayers:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.is_saved', async (data) => { + console.log('Server called client route "is_saved":', data); + const result = await api.isSaved(); + console.log("isSaved:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.saveAs', async (data) => { + console.log('Server called client route "saveAs":', data); + const result = await api.saveAs(data.image_path, data.ext, data.as_copy); + console.log("saveAs:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.duplicate_document', async (data) => { + console.log('Server called client route "duplicate_document":', data); + const result = await api.duplicateDocument(data.newName); + console.log("duplicateDocument:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.close_document', async (data) => { + console.log('Server called client route "close_document":', data); + const result = await api.closeDocument(data.id); + console.log("closed:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.revert_to_previous', async (data) => { + console.log('Server called client route "revert_to_previous":', data); + const result = await api.revertToPrevious(); + console.log("revertToPrevious:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.imprint', async (data) => { + console.log('Server called client route "imprint":', data); + // preserve newlines + const result = await api.imprint(data.payload); + console.log("imprint:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.get_extension_version', async (data) => { + console.log('Server called client route "get_extension_version":', data); + const result = await api.getExtensionVersion(); + console.log("getExtensionVersion:", result); + return result; + } + ); + + RPC.addRoute('Photoshop.get_color_profile_name', async (data) => { + console.log('Server called client route "get_color_profile_name":', data); + const result = await api.getColorProfileName(); + console.log("getColorProfileName:", result); + return result; + } + ) + + RPC.addRoute('Photoshop.close', async (data) => { + console.log('Server called client route "close":', data); + const result = await api.closeApp(); + // probably dead before this + return result; + } + ); + + // Startup ping to validate connection / trigger server-side launch event. + RPC.call('Photoshop.ping').then(function (data) { + console.log('Result for calling server route "ping": ', data); + return "pong"; + }, function (error) { + console.log(error); + }); + +} + +module.exports = { + get_RPC, + setup_rpc +}; diff --git a/extension_uxp/ayon_uxp/client_api.js b/extension_uxp/ayon_uxp/client_api.js new file mode 100644 index 00000000..2ca15189 --- /dev/null +++ b/extension_uxp/ayon_uxp/client_api.js @@ -0,0 +1,750 @@ +const {app, action, core, constants} = require("photoshop") +const uxp_storage = require("uxp").storage +const batchPlay = action.batchPlay; + +// Helper to execute "Modal", Blocking +// Wrapper for functions that need to write. +/** + * @param {function} func + * @param {string} commandName + */ +async function execAsModal(func, commandName) { + return await core.executeAsModal(async () => { + return await func(); + }, {"commandName": commandName}) +} + +/** + * @param {string} path + */ +async function fileOpen(path) { + return await execAsModal(async () => { + const fileEntry = await uxp_storage.localFileSystem.getEntryWithUrl(`file:${path}`); + await app.open(fileEntry); + return path; + }, "Open Document"); +} + +async function save() { + return await execAsModal(async () => { + app.activeDocument.save(); + }, "Save Document"); +} + +async function getActiveDocument() { + const doc = app.activeDocument; + if (!doc){ + return null; + } + return doc; +} + +async function getActiveDocumentFullName() { + const doc = await getActiveDocument(); + if (doc) { + return doc.path; + } else { + return null; + } +} + +async function getActiveDocumentName() { + const doc = await getActiveDocument(); + if (doc) { + return doc.name; + } else { + return null; + } +} + +async function getColorProfileName() { + const doc = await getActiveDocument(); + if (doc) { + return doc.colorProfileName; + } else { + return null; + } +} + +function getLayerTypeWithName(layerName) { + const namePrefix = layerName.split('_')[0].toLowerCase(); + switch (namePrefix) { + case 'guide': + case 'tl': + case 'tr': + case 'bl': + case 'br': + return 'GUIDE'; + case 'fg': + return 'FG'; + case 'bg': + return 'BG'; + case 'obj': + default: + return 'OBJ'; + } +} + +async function getLayers() { + if (app.documents.length === 0) { + return "[]"; + } + + // 1) Get the number of layers in the active document + const [docInfo] = await batchPlay( + [{ + _obj: "get", + _target: [ + { _property: "numberOfLayers" }, + { _ref: "document", _enum: "ordinal", _value: "targetEnum" } + ] + }], + { synchronousExecution: true } + ); + const count = docInfo.numberOfLayers; + + // 2) Build one batchPlay call that fetches every layer's descriptor at once. + // This is dramatically faster than calling batchPlay per layer. + const getCommands = []; + for (let i = count; i >= 1; i--) { + getCommands.push({ + _obj: "get", + _target: [{ _ref: "layer", _index: i }] + }); + } + const descriptors = await batchPlay(getCommands, { synchronousExecution: true }); + + // 3) Walk the descriptors top-to-bottom (highest index first), tracking parents + const layers = []; + const parents = []; + + for (const desc of descriptors) { + const layerSection = desc.layerSection?._value; // "layerSectionContent" | "layerSectionStart" | "layerSectionEnd" + + // Group end marker: pop parent and skip (don't emit a layer) + if (layerSection === "layerSectionEnd") { + parents.pop(); + continue; + } + + const layer = { + id: desc.layerID, + name: desc.name, + color_code: desc.color?._value ?? "none", + group: false, + parents: parents.slice(), + type: getLayerTypeWithName(desc.name), + visible: desc.visible + }; + + if (layerSection === "layerSectionStart") { + layer.group = true; + parents.push(layer.id); + } + + layers.push(layer); + } + + // 4) Background layer (if any) + try { + const bg = app.activeDocument.backgroundLayer; + if (bg) { + layers.push({ + id: bg.id, + name: bg.name, + color_code: "none", + group: false, + parents: [], + type: "background", + visible: bg.visible + }); + } + } catch (e) { + // no background layer + } + + return JSON.stringify(layers); +} + +/** + * Delete the layer with the given id. + */ +async function deleteLayer(layer_id) { + await execAsModal(async () => { + await batchPlay([{ + _obj: "delete", + _target: [{ _ref: "layer", _id: layer_id }], + _options: { dialogOptions: "dontDisplay" } + }], { synchronousExecution: true }); + }, "Delete Layer"); +} + +async function isSaved() { + return app.activeDocument.saved; +} + +/** + * Revert to last saved state of document + */ +async function revertToPrevious() { + await execAsModal(async () => { + await batchPlay( + [{ + _obj: "revert", + _options: { dialogOptions: "dontDisplay" } + }], + { synchronousExecution: true } + ); + }, "Revert"); +} + +/** + * @param {Layer} layer +*/ +async function isLayerGroup(layer) { + return (layer.kind == constants.LayerKind.GROUP); +} + +async function getSelectedLayers() { + if (app.documents.length === 0) return "[]"; + try{ + const selected = app.activeDocument.activeLayers; + const result = selected.map(layer => ({ + id: layer.id, + name: layer.name, + group: layer.kind == constants.LayerKind.GROUP, + long_name: _get_parents_names(layer, layer.name) + })); + + return JSON.stringify(result); + } catch (err) { + console.log(err) + } + return "[]" +} + +/** + * @param {Document} doc + * @param {number} id + * @returns {Layer} + */ +function findLayerById(doc, id) { + function walk(layers) { + for (const l of layers) { + if (l.id === id) return l; + if (l.layers) { + const found = walk(l.layers); + if (found) return found; + } + } + } + return walk(doc.layers); +} + +/** + * Sets layer with given id to the given visibility. + * @param {number} layer_id + * @param {boolean} visibility - true = show, false = hide + */ +async function setVisible(layer_id, visibility) { + const layer = findLayerById(app.activeDocument, layer_id); + if (!layer) return false; + + await execAsModal(async () => { + layer.visible = !!visibility; + }, "Set Layer Visibility"); + + return true; +} + +/** + * Imprints data into the headline of the current document's metadata. + */ +async function imprint(payload) { + await execAsModal(async () => { + await batchPlay( + [ + { + _obj: "set", + _target: [ + { + _ref: "property", + _property: "fileInfo" + }, + { + _ref: "document", + _enum: "ordinal", + _value: "targetEnum" + } + ], + to: { + _obj: "fileInfo", + headline: payload + } + } + ], + { synchronousExecution: true } + ); + }, "Imprint Headline"); +} + +/** + * Returns the headline of the current document's metadata. + */ +async function getHeadline() { + if (app.documents.length === 0) { + return ""; + } + + const result = await batchPlay( + [{ + _obj: "get", + _target: [ + { _property: "fileInfo" }, + { _ref: "document", _enum: "ordinal", _value: "targetEnum" } + ] + }], + { synchronousExecution: true } + ); + + return result[0]?.fileInfo?.headline ?? ""; +} + +function _get_parents_names(layer, itself_name) { + const long_names = [itself_name]; + let current = layer.parent; + + // Walk up while we're still inside a group + while (current && current.kind === constants.LayerKind.GROUP) { + long_names.push(current.name); + current = current.parent; + } + return long_names; +} + +/** + * Selects layers from list of ids + */ +async function selectLayers(selectedLayers) { + if (typeof selectedLayers === "string") { + selectedLayers = JSON.parse(selectedLayers); + } + + const existing = JSON.parse(await getLayers()); + const existingIds = new Set(existing.map(l => l.id)); + + const refs = selectedLayers + .filter(id => existingIds.has(Number(id))) // ✅ cast to number + .map(id => ({ _ref: "layer", _id: Number(id) })); // ✅ cast to number + + if (refs.length === 0) return; + + await execAsModal(async () => { + await batchPlay( + [{ + _obj: "select", + _target: refs, + makeVisible: false, + _options: { dialogOptions: "dontDisplay" } + }], + { synchronousExecution: true } + ); + }, "Select Layers"); +} + +/** + * Rename the layer with the given id. + */ +async function renameLayer(layer_id, new_name) { + const layer = findLayerById(app.activeDocument, layer_id); + if (!layer) return false; + await execAsModal(async () => { + layer.name = new_name; + }, "Rename Layer"); + return true; +} + + +async function createGroup(name) { + let group; + await execAsModal(async () => { + group = await app.activeDocument.createLayerGroup({ name }); + // make sure that active layer is new group, replicating CEP behavior + app.activeDocument.activeLayers = [group]; + }, "Create Group"); + return group.id; +} + + +/** + * Groups currently-selected layers into a new group. + * Returns JSON representation of the created group layer. + */ +async function groupSelectedLayers(doc, name) { + doc = doc || app.activeDocument; + let group; + + await execAsModal(async () => { + await batchPlay( + [{ + _obj: "make", + _target: [{ _ref: "layerSection" }], + from: { _ref: "layer", _enum: "ordinal", _value: "targetEnum" }, + _options: { dialogOptions: "dontDisplay" } + }], + { synchronousExecution: true } + ); + group = doc.activeLayers[0]; + if (name) { + group.name = name; + } + // mimick CEP behavior + await batchPlay( + [{ + _obj: "select", + _target: [{ _ref: "layer", _id: group.id }], + makeVisible: false, + _options: { dialogOptions: "dontDisplay" } + }], + { synchronousExecution: true } + ); + }, "Group Selected Layers"); + + return JSON.stringify({ + id: group.id, + name: name, + group: true, + long_name: _get_parents_names(group, name) + }); +} + +/** + * Select a PS layer by id. Returns nothing; caller can read app.activeDocument.activeLayers. + * NOTE: must be called inside an executeAsModal scope. + */ +async function selectObject(id) { + await batchPlay( + [{ + _obj: "select", + _target: [{ _ref: "layer", _id: id }], + _options: { dialogOptions: "dontDisplay" } + }], + { synchronousExecution: true } + ); +} + +/** + * Delete a layer set and move its child layers to the parent. + */ +async function dissolveLayerSet(layerSetId) { + await execAsModal(async () => { + await selectObject(layerSetId); + const layerSet = app.activeDocument.activeLayers[0]; + + // Snapshot the children (the list mutates as we move them) + const children = layerSet.layers.slice(); + + // Move each child out to the document root (end of stack) + for (const child of children) { + await child.move(app.activeDocument, constants.ElementPlacement.PLACEATEND); + } + + // Delete the now-empty group + await batchPlay( + [{ + _obj: "delete", + _target: [{ _ref: "layer", _id: layerSetId }], + _options: { dialogOptions: "dontDisplay" } + }], + { synchronousExecution: true } + ); + }, "Dissolve Layer Set"); +} + +/** + * Merge all layer sets inside the given parent (or at root if not given), + * preserving each merged result's visibility. + */ +async function mergeAllLayerSets(parentSetId) { + await execAsModal(async () => { + let layerSets; + + if (parentSetId !== undefined && parentSetId !== null && parentSetId !== "undefined") { + await selectObject(parentSetId); + const parent = app.activeDocument.activeLayers[0]; + layerSets = parent.layers.filter(l => l.kind === constants.LayerKind.GROUP); + } else { + layerSets = app.activeDocument.layers.filter(l => l.kind === constants.LayerKind.GROUP); + } + + // Iterate in reverse so indices stay valid as we mutate the stack + for (let i = layerSets.length - 1; i >= 0; i--) { + const ls = layerSets[i]; + const visibility = ls.visible; + const merged = await ls.merge(); + merged.visible = visibility; + } + }, "Merge All Layer Sets"); +} + +/** + * Place an image as a smart object. + * path: absolute path to file + * name: optional name for the new layer + * link: if true, place as a linked smart object instead of embedded + */ +async function importSmartObject(path, name, link) { + let normalizedPath = path.replace(/\\/g, "/"); + let layer; + // Somehow, "▼" shows up in names coming from ayon leading to illegal readouts. + name = name.replace("▼","") + //if (os.platform() === "win32") { + // normalizedPath = "file:///" + normalizedPath; + //} else { + // normalizedPath = "file://" + normalizedPath; + //} + + await execAsModal(async () => { + //const fileEntry = await uxp_storage.localFileSystem.createEntryWithUrl(normalizedPath, { overwrite: true }); + //console.log(fileEntry, typeof fileEntry) + console.log(normalizedPath) + const fileEntry = await uxp_storage.localFileSystem.createEntryWithUrl(normalizedPath, { overwrite: true }); + let sessionToken = uxp_storage.localFileSystem.createSessionToken(fileEntry); + const command = { + _obj: "placeEvent", + null: { _path: sessionToken, _kind: "local" }, + freeTransformCenterState: { + _enum: "quadCenterState", + _value: "QCSAverage" + }, + offset: { + _obj: "offset", + horizontal: { _unit: "pixelsUnit", _value: 0.0 }, + vertical: { _unit: "pixelsUnit", _value: 0.0 } + }, + _options: { dialogOptions: "dontDisplay" } + }; + + if (link) { + command.linked = true; + } + + await batchPlay([command], { synchronousExecution: true }); + + layer = app.activeDocument.activeLayers[0]; + if (name) { + layer.name = name; + } + }, "Import Smart Object"); + + return JSON.stringify({ + id: layer.id, + name: layer.name + }); +} + + +/** + * Replace the content of an existing smart-object layer. + */ +async function replaceSmartObjects(layer_id, path, name) { + let normalizedPath = path.replace(/\\/g, "/"); + name = name.replace("▼","") + return await execAsModal(async () => { + const fileEntry = await uxp_storage.localFileSystem.createEntryWithUrl(normalizedPath, { overwrite: true }); + let sessionToken = uxp_storage.localFileSystem.createSessionToken(fileEntry); + await batchPlay( + [{ + _obj: "placedLayerReplaceContents", + _target: [{ _ref: "layer", _id: layer_id }], + null: { _path: sessionToken, _kind: "local" }, + pageNumber: 1, + _options: { dialogOptions: "dontDisplay" } + }], + { synchronousExecution: true } + ); + + if (name) { + const layer = app.activeDocument.activeLayers[0]; + layer.name = name; + } + }, "Replace Smart Object"); +} + +async function saveAs(imagePath, ext, asCopy) { + asCopy = !!asCopy; + const format = (ext || "").toLowerCase(); + const url = "file:" + imagePath.replace(/\\/g, "/"); + + await execAsModal(async () => { + const doc = app.activeDocument; + + switch (format) { + case "jpg": + case "jpeg": { + const entry = await uxp_storage.localFileSystem.createEntryWithUrl(url, { overwrite: true }); + await doc.saveAs.jpg(entry, { + quality: 12, + embedColorProfile: true, + formatOptions: "progressive", + scans: 5, + matte: "noMatte" + }, asCopy); + break; + } + + case "png": { + const entry = await uxp_storage.localFileSystem.createEntryWithUrl(url, { overwrite: true }); + await doc.saveAs.png(entry, { + compression: 6, + interlaced: true + }, asCopy); + break; + } + + case "psd": { + const entry = await uxp_storage.localFileSystem.createEntryWithUrl(url, { overwrite: true }); + await doc.saveAs.psd(entry, { + embedColorProfile: true, + alphaChannels: true, + layers: true, + annotations: true, + spotColors: true, + maximizeCompatibility: true + }, asCopy); + break; + } + + case "psb": { + const entry = await uxp_storage.localFileSystem.createEntryWithUrl(url, { overwrite: true }); + await doc.saveAs.psb(entry, { + embedColorProfile: true, + alphaChannels: true, + layers: true, + annotations: true, + spotColors: true, + maximizeCompatibility: true // required for PSB + }, asCopy); + break; + } + + case "tga": + await batchPlay([{ + _obj: "save", + as: { + _obj: "targaFormat", + depth: 32, + alphaChannels: true, + rleCompression: true + }, + in: { _path: imagePath, _kind: "local" }, + copy: asCopy, + lowerCase: true, + _options: { dialogOptions: "dontDisplay" } + }], { synchronousExecution: true }); + break; + + default: + throw new Error("saveAs: unsupported extension '" + ext + "'"); + } + }, "Save As"); + + return imagePath; +} + +/** + * Duplicate the active document. + * @param {string} newName - name for the duplicated document + * @returns {number} - id of the duplicated document + */ +async function duplicateDocument(newName) { + let newDoc; + await execAsModal(async () => { + newDoc = await app.activeDocument.duplicate(newName); + }, "Duplicate Document"); + return newDoc.id; +} + +/** + * Close document with given ID. If no ID, closes the active document. + * @param {number} [documentId] + * @throws if a documentId is given but not found + */ +async function closeDocument(documentId) { + let document; + if (documentId === undefined || documentId === null) { + document = app.activeDocument; + } else { + document = app.documents.find(d => d.id === documentId); + if (!document) { + throw new Error("Document with ID " + documentId + " not found."); + } + } + + if (!document) return false; // no active document case + + await execAsModal(async () => { + await document.closeWithoutSaving(); + }, "Close Document"); + + return true; +} + +/** + * Returns version number from manifest.json (UXP) + */ +async function getExtensionVersion() { + try { + const pluginFolder = await uxp_storage.localFileSystem.getPluginFolder(); + const manifestEntry = await pluginFolder.getEntry("manifest.json"); + const manifestText = await manifestEntry.read(); + + // Parse manifest JSON + const manifest = JSON.parse(manifestText); + // UXP version field + return manifest.version || null; + + } catch (err) { + console.error("getExtensionVersion failed:", err); + return null; + } +} + +async function closeApp() { + await app.terminate(); +} + +module.exports = { + execAsModal, + fileOpen, + save, + getActiveDocument, + getActiveDocumentFullName, + getActiveDocumentName, + getColorProfileName, + getLayerTypeWithName, + getLayers, + deleteLayer, + isSaved, + revertToPrevious, + isLayerGroup, + getSelectedLayers, + findLayerById, + setVisible, + imprint, + getHeadline, + selectLayers, + renameLayer, + createGroup, + groupSelectedLayers, + selectObject, + dissolveLayerSet, + mergeAllLayerSets, + importSmartObject, + replaceSmartObjects, + saveAs, + duplicateDocument, + closeDocument, + getExtensionVersion, + closeApp, +}; diff --git a/extension_uxp/ayon_uxp/com.ayon.photoshop_PS.ccx b/extension_uxp/ayon_uxp/com.ayon.photoshop_PS.ccx new file mode 100644 index 00000000..d0f421c6 Binary files /dev/null and b/extension_uxp/ayon_uxp/com.ayon.photoshop_PS.ccx differ diff --git a/extension_uxp/ayon_uxp/icons/ayon_logo.png b/extension_uxp/ayon_uxp/icons/ayon_logo.png new file mode 100644 index 00000000..3a96f8e2 Binary files /dev/null and b/extension_uxp/ayon_uxp/icons/ayon_logo.png differ diff --git a/extension_uxp/ayon_uxp/index.html b/extension_uxp/ayon_uxp/index.html new file mode 100644 index 00000000..56856b16 --- /dev/null +++ b/extension_uxp/ayon_uxp/index.html @@ -0,0 +1,71 @@ + + + + + + + + + +
+
Workfiles...
+
Load...
+
Publish...
+
Manage...
+
Experimental Tools...
+

Plugin converted by Sas van Gulik
@ Studio Submarine

+
+ + + + \ No newline at end of file diff --git a/extension_uxp/ayon_uxp/lib/wsrpc.js b/extension_uxp/ayon_uxp/lib/wsrpc.js new file mode 100644 index 00000000..be48649f --- /dev/null +++ b/extension_uxp/ayon_uxp/lib/wsrpc.js @@ -0,0 +1,393 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = global || self, global.WSRPC = factory()); +}(this, function () { 'use strict'; + + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + var Deferred = function Deferred() { + _classCallCheck(this, Deferred); + + var self = this; + self.resolve = null; + self.reject = null; + self.done = false; + + function wrapper(func) { + return function () { + if (self.done) throw new Error('Promise already done'); + self.done = true; + return func.apply(this, arguments); + }; + } + + self.promise = new Promise(function (resolve, reject) { + self.resolve = wrapper(resolve); + self.reject = wrapper(reject); + }); + + self.promise.isPending = function () { + return !self.done; + }; + + return self; + }; + + function logGroup(group, level, args) { + console.group(group); + console[level].apply(this, args); + console.groupEnd(); + } + + function log() { + if (!WSRPC.DEBUG) return; + logGroup('WSRPC.DEBUG', 'trace', arguments); + } + + function trace(msg) { + if (!WSRPC.TRACE) return; + var payload = msg; + if ('data' in msg) payload = JSON.parse(msg.data); + logGroup("WSRPC.TRACE", 'trace', [payload]); + } + + function getAbsoluteWsUrl(url) { + if (/^\w+:\/\//.test(url)) return url; + if (typeof window == 'undefined' && window.location.host.length < 1) throw new Error("Can not construct absolute URL from ".concat(window.location)); + var scheme = window.location.protocol === "https:" ? "wss:" : "ws:"; + var port = window.location.port === '' ? ":".concat(window.location.port) : ''; + var host = window.location.host; + var path = url.replace(/^\/+/gm, ''); + return "".concat(scheme, "//").concat(host).concat(port, "/").concat(path); + } + + var readyState = Object.freeze({ + 0: 'CONNECTING', + 1: 'OPEN', + 2: 'CLOSING', + 3: 'CLOSED' + }); + + var WSRPC = function WSRPC(URL) { + var reconnectTimeout = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1000; + + _classCallCheck(this, WSRPC); + + var self = this; + URL = getAbsoluteWsUrl(URL); + self.id = 1; + self.eventId = 0; + self.socketStarted = false; + self.eventStore = { + onconnect: {}, + onerror: {}, + onclose: {}, + onchange: {} + }; + self.connectionNumber = 0; + self.oneTimeEventStore = { + onconnect: [], + onerror: [], + onclose: [], + onchange: [] + }; + self.callQueue = []; + + function createSocket() { + var ws = new WebSocket(URL); + + var rejectQueue = function rejectQueue() { + self.connectionNumber++; // rejects incoming calls + + var deferred; //reject all pending calls + + while (0 < self.callQueue.length) { + var callObj = self.callQueue.shift(); + deferred = self.store[callObj.id]; + delete self.store[callObj.id]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } // reject all from the store + + + for (var key in self.store) { + if (!self.store.hasOwnProperty(key)) continue; + deferred = self.store[key]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } + }; + + function reconnect(callEvents) { + setTimeout(function () { + try { + self.socket = createSocket(); + self.id = 1; + } catch (exc) { + callEvents('onerror', exc); + delete self.socket; + console.error(exc); + } + }, reconnectTimeout); + } + + ws.onclose = function (err) { + log('ONCLOSE CALLED', 'STATE', self.public.state()); + trace(err); + + for (var serial in self.store) { + if (!self.store.hasOwnProperty(serial)) continue; + + if (self.store[serial].hasOwnProperty('reject')) { + self.store[serial].reject('Connection closed'); + } + } + + rejectQueue(); + callEvents('onclose', err); + callEvents('onchange', err); + reconnect(callEvents); + }; + + ws.onerror = function (err) { + log('ONERROR CALLED', 'STATE', self.public.state()); + trace(err); + rejectQueue(); + callEvents('onerror', err); + callEvents('onchange', err); + log('WebSocket has been closed by error: ', err); + }; + + function tryCallEvent(func, event) { + try { + return func(event); + } catch (e) { + if (e.hasOwnProperty('stack')) { + log(e.stack); + } else { + log('Event function', func, 'raised unknown error:', e); + } + + console.error(e); + } + } + + function callEvents(evName, event) { + while (0 < self.oneTimeEventStore[evName].length) { + var deferred = self.oneTimeEventStore[evName].shift(); + if (deferred.hasOwnProperty('resolve') && deferred.promise.isPending()) deferred.resolve(); + } + + for (var i in self.eventStore[evName]) { + if (!self.eventStore[evName].hasOwnProperty(i)) continue; + var cur = self.eventStore[evName][i]; + tryCallEvent(cur, event); + } + } + + ws.onopen = function (ev) { + log('ONOPEN CALLED', 'STATE', self.public.state()); + trace(ev); + + while (0 < self.callQueue.length) { + // noinspection JSUnresolvedFunction + self.socket.send(JSON.stringify(self.callQueue.shift(), 0, 1)); + } + + callEvents('onconnect', ev); + callEvents('onchange', ev); + }; + + function handleCall(self, data) { + if (!self.routes.hasOwnProperty(data.method)) throw new Error('Route not found'); + var connectionNumber = self.connectionNumber; + var deferred = new Deferred(); + deferred.promise.then(function (result) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + result: result + })); + }, function (error) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + error: error + })); + }); + var func = self.routes[data.method]; + if (self.asyncRoutes[data.method]) return func.apply(deferred, [data.params]); + + function badPromise() { + throw new Error("You should register route with async flag."); + } + + var promiseMock = { + resolve: badPromise, + reject: badPromise + }; + + try { + deferred.resolve(func.apply(promiseMock, [data.params])); + } catch (e) { + deferred.reject(e); + console.error(e); + } + } + + function handleError(self, data) { + if (!self.store.hasOwnProperty(data.id)) return log('Unknown callback'); + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + log('REJECTING', data.error); + deferred.reject(data.error); + } + + function handleResult(self, data) { + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + + if (data.hasOwnProperty('result')) { + return deferred.resolve(data.result); + } + + return deferred.reject(data.error); + } + + ws.onmessage = function (message) { + log('ONMESSAGE CALLED', 'STATE', self.public.state()); + trace(message); + if (message.type !== 'message') return; + var data; + + try { + data = JSON.parse(message.data); + log(data); + + if (data.hasOwnProperty('method')) { + return handleCall(self, data); + } else if (data.hasOwnProperty('error') && data.error === null) { + return handleError(self, data); + } else { + return handleResult(self, data); + } + } catch (exception) { + var err = { + error: exception.message, + result: null, + id: data ? data.id : null + }; + self.socket.send(JSON.stringify(err)); + console.error(exception); + } + }; + + return ws; + } + + function makeCall(func, args, params) { + self.id += 2; + var deferred = new Deferred(); + var callObj = Object.freeze({ + id: self.id, + method: func, + params: args + }); + var state = self.public.state(); + + if (state === 'OPEN') { + self.store[self.id] = deferred; + self.socket.send(JSON.stringify(callObj)); + } else if (state === 'CONNECTING') { + log('SOCKET IS', state); + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } else { + log('SOCKET IS', state); + + if (params && params['noWait']) { + deferred.reject("Socket is: ".concat(state)); + } else { + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } + } + + return deferred.promise; + } + + self.asyncRoutes = {}; + self.routes = {}; + self.store = {}; + self.public = Object.freeze({ + call: function call(func, args, params) { + return makeCall(func, args, params); + }, + addRoute: function addRoute(route, callback, isAsync) { + self.asyncRoutes[route] = isAsync || false; + self.routes[route] = callback; + }, + deleteRoute: function deleteRoute(route) { + delete self.asyncRoutes[route]; + return delete self.routes[route]; + }, + addEventListener: function addEventListener(event, func) { + var eventId = self.eventId++; + self.eventStore[event][eventId] = func; + return eventId; + }, + removeEventListener: function removeEventListener(event, index) { + if (self.eventStore[event].hasOwnProperty(index)) { + delete self.eventStore[event][index]; + return true; + } else { + return false; + } + }, + onEvent: function onEvent(event) { + var deferred = new Deferred(); + self.oneTimeEventStore[event].push(deferred); + return deferred.promise; + }, + destroy: function destroy() { + return self.socket.close(); + }, + state: function state() { + return readyState[this.stateCode()]; + }, + stateCode: function stateCode() { + if (self.socketStarted && self.socket) return self.socket.readyState; + return 3; + }, + connect: function connect() { + self.socketStarted = true; + self.socket = createSocket(); + } + }); + self.public.addRoute('log', function (argsObj) { + //console.info("Websocket sent: ".concat(argsObj)); + }); + self.public.addRoute('ping', function (data) { + return data; + }); + return self.public; + }; + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + return WSRPC; + +})); +//# sourceMappingURL=wsrpc.js.map diff --git a/extension_uxp/ayon_uxp/lib/wsrpc.min.js b/extension_uxp/ayon_uxp/lib/wsrpc.min.js new file mode 100644 index 00000000..f1264b91 --- /dev/null +++ b/extension_uxp/ayon_uxp/lib/wsrpc.min.js @@ -0,0 +1 @@ +!function(global,factory){"object"==typeof exports&&"undefined"!=typeof module?module.exports=factory():"function"==typeof define&&define.amd?define(factory):(global=global||self).WSRPC=factory()}(this,function(){"use strict";function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function Deferred(){_classCallCheck(this,Deferred);var self=this;function wrapper(func){return function(){if(!self.done)return self.done=!0,func.apply(this,arguments);console.error(new Error("Promise already done"))}}return self.resolve=null,self.reject=null,self.done=!1,self.promise=new Promise(function(resolve,reject){self.resolve=wrapper(resolve),self.reject=wrapper(reject)}),self.promise.isPending=function(){return!self.done},self}function logGroup(group,level,args){console.group(group),console[level].apply(this,args),console.groupEnd()}function log(){WSRPC.DEBUG&&logGroup("WSRPC.DEBUG","trace",arguments)}function trace(msg){if(WSRPC.TRACE){var payload=msg;"data"in msg&&(payload=JSON.parse(msg.data)),logGroup("WSRPC.TRACE","trace",[payload])}}var readyState=Object.freeze({0:"CONNECTING",1:"OPEN",2:"CLOSING",3:"CLOSED"}),WSRPC=function WSRPC(URL){var reconnectTimeout=1 { + // Ensure that RPC is ready. + await setup_rpc(WS_URL); + RPC = await get_RPC(); + console.log("Got RPC!") + async function bind(id, route) { + const el = document.getElementById(id); + if (!el) return; + + el.addEventListener("click", async () => { + try { + const result = await RPC.call(route); + console.log(`Success: ${route}`, result); + } catch (err) { + console.error(`Failed: ${route}`, err); + } + }); + } + + // Bind buttons. + await bind("workfiles-button", "Photoshop.workfiles_route"); + await bind("loader-button", "Photoshop.loader_route"); + await bind("publish-button", "Photoshop.publish_route"); + await bind("sceneinventory-button", "Photoshop.sceneinventory_route"); + await bind("experimental-button", "Photoshop.experimental_tools_route"); +}); \ No newline at end of file diff --git a/extension_uxp/ayon_uxp/manifest.json b/extension_uxp/ayon_uxp/manifest.json new file mode 100644 index 00000000..6bc0cc5b --- /dev/null +++ b/extension_uxp/ayon_uxp/manifest.json @@ -0,0 +1,61 @@ +{ + "manifestVersion": 5, + "id": "com.ayon.photoshop", + "name": "AYON", + "version": "1.1.11", + "main": "index.html", + "host": { + "app": "PS", + "minVersion": "24.0.0" + }, + "entrypoints": [ + { + "type": "panel", + "id": "ayon-panel", + "label": { + "default": "AYON" + }, + "minimumSize": { + "width": 300, + "height": 200 + }, + "maximumSize": { + "width": 300, + "height": 200 + }, + "preferredDockedSize": { + "width": 300, + "height": 200 + }, + "preferredFloatingSize": { + "width": 300, + "height": 200 + }, + "icons": [ + { + "width": 24, + "height": 24, + "path": "./icons/ayon_logo.png", + "scale": [1, 2] + } + ] + } + ], + "icons": [ + { + "width": 24, + "height": 24, + "path": "./icons/ayon_logo.png", + "scale": [1, 2] + } + ], + "requiredPermissions": { + "network": { + "domains": [ + "all", + "ws://localhost:8101" + ] + }, + "localFileSystem": "fullAccess" + } +} \ No newline at end of file diff --git a/extension_uxp/package-lock.json b/extension_uxp/package-lock.json new file mode 100644 index 00000000..da407c4b --- /dev/null +++ b/extension_uxp/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "extension_uxp_develop", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@adobe-uxp-types/photoshop": "^0.1.7" + } + }, + "node_modules/@adobe-uxp-types/photoshop": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@adobe-uxp-types/photoshop/-/photoshop-0.1.7.tgz", + "integrity": "sha512-8Akidly9zZ1gv5yxka6aay0IMFgJB2Rg5te+Ju2DmWkuf8ZCF7wI9lbLpe+ctqS32x+IK9ws0CFRTmX3WE4UoA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@adobe-uxp-types/uxp": "0.1.3" + } + }, + "node_modules/@adobe-uxp-types/uxp": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@adobe-uxp-types/uxp/-/uxp-0.1.3.tgz", + "integrity": "sha512-gAN9csouNd+kFAiwSYi/5XZSg9Tnud9TtVgtqIu//BGvLRkb1wHn6Fk/1W+uLE0lAnvaezhANoHKN7YTsu+EWA==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/extension_uxp/package.json b/extension_uxp/package.json new file mode 100644 index 00000000..7bc86de6 --- /dev/null +++ b/extension_uxp/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@adobe-uxp-types/photoshop": "^0.1.7" + } +}