初始化 Photoshop AI 编辑面板
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.debug
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# AI Edit
|
||||||
|
|
||||||
|
Photoshop CEP panel for image generation. It sends text prompts to your own image API, previews the result, and places generated PNGs into the active document as new masked layers.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Layer | File | Role |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Panel UI | `src/panels/ImageGenerator.jsx` | React + Tailwind, runs in the CEP CEF panel |
|
||||||
|
| Bridge | `src/cep.js` | Node file/network access and `evalScript` calls |
|
||||||
|
| Host | `cep/host/host.jsx` | ExtendScript: masked layer placement, file and prompt dialogs |
|
||||||
|
| Manifest | `cep/CSXS/manifest.xml` | CEP extension declaration |
|
||||||
|
|
||||||
|
Node runs in the panel (`--enable-nodejs --mixed-context`), so temp files and HTTP
|
||||||
|
both go through Node rather than UXP storage or `fetch`.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Tailwind CSS 3, compiled by PostCSS into `dist/styles/panel.css`. Utility classes
|
||||||
|
carry the exact pixel values from the original design, so arbitrary values
|
||||||
|
(`h-[47px]`, `bg-[#2f2f2f]`) are used throughout rather than the default scale.
|
||||||
|
|
||||||
|
- `tailwind.config.js` — accent color, the rounded font stack, and the `narrow` (max 340px) breakpoint
|
||||||
|
- `src/index.css` — Tailwind entry plus base rules for `html`, `body`, and `#root`
|
||||||
|
|
||||||
|
`src/styles.css` and `src/panels/ImageGenerator.css` are the pre-Tailwind
|
||||||
|
originals. Nothing imports them; they are kept as the reference for the
|
||||||
|
original pixel values.
|
||||||
|
|
||||||
|
## Prompt Input
|
||||||
|
|
||||||
|
The prompt box is a `contentEditable` div. CEF does not reliably receive IME
|
||||||
|
composition events, so the button above 词库 opens a native Photoshop
|
||||||
|
ScriptUI window for typing Chinese. Text crosses `evalScript` as
|
||||||
|
`escape()`-encoded ASCII in both directions.
|
||||||
|
|
||||||
|
## API Shape
|
||||||
|
|
||||||
|
The default request is compatible with OpenAI-style image endpoints:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "gpt-image-2",
|
||||||
|
"prompt": "your prompt",
|
||||||
|
"n": 1,
|
||||||
|
"size": "1024x1024",
|
||||||
|
"response_format": "b64_json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted response shapes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "data": [{ "b64_json": "..." }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "data": [{ "url": "https://..." }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`output` and `images` arrays are also tolerated for gateway variants.
|
||||||
|
|
||||||
|
## Photoshop Placement
|
||||||
|
|
||||||
|
Generated images first appear only in the panel's thumbnail list. Clicking a
|
||||||
|
thumbnail places it as an embedded smart-object layer with a new layer mask.
|
||||||
|
The selection that existed when generation started is saved as a temporary
|
||||||
|
alpha channel, so the mask still uses the original selection even if the user
|
||||||
|
changes the selection before clicking. Without a selection, the layer gets a
|
||||||
|
reveal-all mask.
|
||||||
|
|
||||||
|
When a selection exists, its merged visible pixels are exported as the first
|
||||||
|
reference image. Uploaded reference files follow it. The panel sends these
|
||||||
|
images as data URLs in the request body's `images` array.
|
||||||
|
|
||||||
|
The request size is read from Photoshop: the full canvas dimensions are used
|
||||||
|
without a selection, and the selection bounds are used when a selection exists.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output goes to `dist`, which is the extension folder itself (`dist/CSXS/manifest.xml`).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run cep:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables `PlayerDebugMode` for CSXS 9–12 and links `dist` into the Adobe
|
||||||
|
extensions folder. On Windows it writes to `HKCU\Software\Adobe\CSXS.*` and
|
||||||
|
creates a directory junction; on macOS it uses `defaults write` and a symlink.
|
||||||
|
|
||||||
|
Restart Photoshop, then open the panel from `Plugins`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run watch
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload the panel from its flyout menu, or open `http://localhost:8088` for
|
||||||
|
Chrome DevTools (the port comes from `cep/.debug`).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ExtensionManifest Version="9.0"
|
||||||
|
ExtensionBundleId="com.aiedit.panel"
|
||||||
|
ExtensionBundleName="AI Edit"
|
||||||
|
ExtensionBundleVersion="1.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
<ExtensionList>
|
||||||
|
<Extension Id="com.aiedit.panel.main" Version="1.0.0" />
|
||||||
|
</ExtensionList>
|
||||||
|
<ExecutionEnvironment>
|
||||||
|
<HostList>
|
||||||
|
<Host Name="PHXS" Version="[20.0,99.9]" />
|
||||||
|
<Host Name="PHSP" Version="[20.0,99.9]" />
|
||||||
|
</HostList>
|
||||||
|
<LocaleList>
|
||||||
|
<Locale Code="All" />
|
||||||
|
</LocaleList>
|
||||||
|
<RequiredRuntimeList>
|
||||||
|
<RequiredRuntime Name="CSXS" Version="9.0" />
|
||||||
|
</RequiredRuntimeList>
|
||||||
|
</ExecutionEnvironment>
|
||||||
|
<DispatchInfoList>
|
||||||
|
<Extension Id="com.aiedit.panel.main">
|
||||||
|
<DispatchInfo>
|
||||||
|
<Resources>
|
||||||
|
<MainPath>./index.html</MainPath>
|
||||||
|
<ScriptPath>./host/host.jsx</ScriptPath>
|
||||||
|
<CEFCommandLine>
|
||||||
|
<Parameter>--enable-nodejs</Parameter>
|
||||||
|
<Parameter>--mixed-context</Parameter>
|
||||||
|
</CEFCommandLine>
|
||||||
|
</Resources>
|
||||||
|
<Lifecycle>
|
||||||
|
<AutoVisible>true</AutoVisible>
|
||||||
|
</Lifecycle>
|
||||||
|
<UI>
|
||||||
|
<Type>Panel</Type>
|
||||||
|
<Menu>AI 生图</Menu>
|
||||||
|
<Geometry>
|
||||||
|
<Size>
|
||||||
|
<Height>876</Height>
|
||||||
|
<Width>390</Width>
|
||||||
|
</Size>
|
||||||
|
<MinSize>
|
||||||
|
<Height>420</Height>
|
||||||
|
<Width>300</Width>
|
||||||
|
</MinSize>
|
||||||
|
<MaxSize>
|
||||||
|
<Height>2000</Height>
|
||||||
|
<Width>2000</Width>
|
||||||
|
</MaxSize>
|
||||||
|
</Geometry>
|
||||||
|
<Icons>
|
||||||
|
<Icon Type="Normal">./icons/icon_N.png</Icon>
|
||||||
|
<Icon Type="DarkNormal">./icons/icon_D.png</Icon>
|
||||||
|
</Icons>
|
||||||
|
</UI>
|
||||||
|
</DispatchInfo>
|
||||||
|
</Extension>
|
||||||
|
</DispatchInfoList>
|
||||||
|
</ExtensionManifest>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// ExtendScript host for the AI Edit CEP panel.
|
||||||
|
// ASCII only: ExtendScript guesses this file's encoding, so all localized
|
||||||
|
// strings are passed in from the panel instead of written here.
|
||||||
|
|
||||||
|
function aiEditHasDocument() {
|
||||||
|
try {
|
||||||
|
return app.documents.length > 0 ? "yes" : "no";
|
||||||
|
} catch (e) {
|
||||||
|
return "no";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiEditShowComposite(doc) {
|
||||||
|
try {
|
||||||
|
doc.activeChannels = doc.componentChannels;
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
doc.activeChannels = [doc.channels[0]];
|
||||||
|
} catch (fallbackError) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiEditPickImages(title, filter) {
|
||||||
|
try {
|
||||||
|
var picked = File.openDialog(title, filter, true);
|
||||||
|
if (!picked) return "";
|
||||||
|
if (!(picked instanceof Array)) picked = [picked];
|
||||||
|
|
||||||
|
var paths = [];
|
||||||
|
for (var i = 0; i < picked.length; i++) paths.push(picked[i].fsName);
|
||||||
|
return paths.join("\n");
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A native window is the reliable way to type CJK: the CEF panel does not
|
||||||
|
// always receive IME composition events.
|
||||||
|
function aiEditPromptDialog(title, label, okText, cancelText, initial) {
|
||||||
|
try {
|
||||||
|
var dialog = new Window("dialog", title);
|
||||||
|
dialog.alignChildren = "fill";
|
||||||
|
dialog.margins = 14;
|
||||||
|
dialog.spacing = 10;
|
||||||
|
|
||||||
|
dialog.add("statictext", undefined, label);
|
||||||
|
|
||||||
|
var field = dialog.add("edittext", undefined, initial || "", {
|
||||||
|
multiline: true,
|
||||||
|
wantReturn: true
|
||||||
|
});
|
||||||
|
field.preferredSize = [420, 150];
|
||||||
|
|
||||||
|
var row = dialog.add("group");
|
||||||
|
row.alignment = "right";
|
||||||
|
row.spacing = 8;
|
||||||
|
var cancel = row.add("button", undefined, cancelText, { name: "cancel" });
|
||||||
|
var confirm = row.add("button", undefined, okText, { name: "ok" });
|
||||||
|
|
||||||
|
var text = null;
|
||||||
|
confirm.onClick = function () {
|
||||||
|
text = field.text;
|
||||||
|
dialog.close(1);
|
||||||
|
};
|
||||||
|
cancel.onClick = function () {
|
||||||
|
dialog.close(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
field.active = true;
|
||||||
|
if (dialog.show() !== 1 || text === null) return "cancel";
|
||||||
|
|
||||||
|
// escape() keeps the result ASCII so evalScript can carry it back.
|
||||||
|
return "ok:" + escape(text);
|
||||||
|
} catch (e) {
|
||||||
|
return "cancel";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiEditGetGenerationSize() {
|
||||||
|
try {
|
||||||
|
if (app.documents.length === 0) return "";
|
||||||
|
var doc = app.activeDocument;
|
||||||
|
var width = doc.width.as("px");
|
||||||
|
var height = doc.height.as("px");
|
||||||
|
try {
|
||||||
|
var bounds = doc.selection.bounds;
|
||||||
|
width = bounds[2].as("px") - bounds[0].as("px");
|
||||||
|
height = bounds[3].as("px") - bounds[1].as("px");
|
||||||
|
} catch (noSelection) {}
|
||||||
|
return Math.max(1, Math.round(width)) + "x" + Math.max(1, Math.round(height));
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiEditCaptureSelection() {
|
||||||
|
var doc = null;
|
||||||
|
var reference = null;
|
||||||
|
try {
|
||||||
|
if (app.documents.length === 0) return "none";
|
||||||
|
doc = app.activeDocument;
|
||||||
|
aiEditShowComposite(doc);
|
||||||
|
|
||||||
|
var bounds;
|
||||||
|
try {
|
||||||
|
bounds = doc.selection.bounds;
|
||||||
|
} catch (selectionError) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = "AI Edit Selection " + new Date().getTime();
|
||||||
|
var channel = doc.channels.add();
|
||||||
|
channel.name = token;
|
||||||
|
doc.selection.store(channel);
|
||||||
|
aiEditShowComposite(doc);
|
||||||
|
|
||||||
|
var width = Math.max(1, Math.round(bounds[2].as("px") - bounds[0].as("px")));
|
||||||
|
var height = Math.max(1, Math.round(bounds[3].as("px") - bounds[1].as("px")));
|
||||||
|
var boundsText = [
|
||||||
|
Math.round(bounds[0].as("px")),
|
||||||
|
Math.round(bounds[1].as("px")),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
// Avoid copy/copyMerged: Photoshop reports that command as unavailable
|
||||||
|
// for some active layers and documents. A duplicate keeps the merged
|
||||||
|
// visible pixels and can be cropped without using the clipboard.
|
||||||
|
reference = doc.duplicate("AI Edit Reference", false);
|
||||||
|
reference.flatten();
|
||||||
|
// Do not call selection.clear here. Photoshop rejects that command
|
||||||
|
// when the duplicated document resolves to a locked background layer.
|
||||||
|
// Cropping to the captured bounds keeps the selected image usable as
|
||||||
|
// a reference without changing pixels through the clipboard/delete UI.
|
||||||
|
reference.crop(bounds);
|
||||||
|
|
||||||
|
var file = new File(Folder.temp.fsName + "/ai-edit-selection-" + new Date().getTime() + ".png");
|
||||||
|
var options = new PNGSaveOptions();
|
||||||
|
reference.saveAs(file, options, true, Extension.LOWERCASE);
|
||||||
|
reference.close(SaveOptions.DONOTSAVECHANGES);
|
||||||
|
app.activeDocument = doc;
|
||||||
|
aiEditShowComposite(doc);
|
||||||
|
return "ok:" + token + "|" + file.fsName + "|" + boundsText;
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
if (reference) reference.close(SaveOptions.DONOTSAVECHANGES);
|
||||||
|
if (doc) {
|
||||||
|
app.activeDocument = doc;
|
||||||
|
aiEditShowComposite(doc);
|
||||||
|
}
|
||||||
|
} catch (closeError) {}
|
||||||
|
return "error:" + e.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiEditAddLayerMask(hasSelection) {
|
||||||
|
var classicMask = new ActionDescriptor();
|
||||||
|
classicMask.putClass(charIDToTypeID("Nw "), charIDToTypeID("Chnl"));
|
||||||
|
|
||||||
|
var classicTarget = new ActionReference();
|
||||||
|
classicTarget.putEnumerated(
|
||||||
|
charIDToTypeID("Chnl"),
|
||||||
|
charIDToTypeID("Chnl"),
|
||||||
|
charIDToTypeID("Msk ")
|
||||||
|
);
|
||||||
|
classicMask.putReference(charIDToTypeID("At "), classicTarget);
|
||||||
|
classicMask.putEnumerated(
|
||||||
|
charIDToTypeID("Usng"),
|
||||||
|
charIDToTypeID("UsrM"),
|
||||||
|
charIDToTypeID(hasSelection ? "RvlS" : "RvlA")
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
executeAction(charIDToTypeID("Mk "), classicMask, DialogModes.NO);
|
||||||
|
return;
|
||||||
|
} catch (classicError) {}
|
||||||
|
|
||||||
|
var modernMask = new ActionDescriptor();
|
||||||
|
var newChannel = new ActionReference();
|
||||||
|
newChannel.putClass(stringIDToTypeID("channel"));
|
||||||
|
modernMask.putReference(charIDToTypeID("null"), newChannel);
|
||||||
|
|
||||||
|
var currentLayer = new ActionReference();
|
||||||
|
currentLayer.putEnumerated(
|
||||||
|
stringIDToTypeID("layer"),
|
||||||
|
stringIDToTypeID("ordinal"),
|
||||||
|
stringIDToTypeID("targetEnum")
|
||||||
|
);
|
||||||
|
modernMask.putReference(charIDToTypeID("At "), currentLayer);
|
||||||
|
modernMask.putEnumerated(
|
||||||
|
stringIDToTypeID("using"),
|
||||||
|
stringIDToTypeID("userMaskEnabled"),
|
||||||
|
stringIDToTypeID(hasSelection ? "revealSelection" : "revealAll")
|
||||||
|
);
|
||||||
|
executeAction(charIDToTypeID("Mk "), modernMask, DialogModes.NO);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiEditPlaceImage(filePath, layerName, channelName, boundsText) {
|
||||||
|
try {
|
||||||
|
if (app.documents.length === 0) return "error:missing-document";
|
||||||
|
|
||||||
|
var file = new File(filePath);
|
||||||
|
if (!file.exists) return "error:missing-file";
|
||||||
|
|
||||||
|
var doc = app.activeDocument;
|
||||||
|
var selectionChannel = null;
|
||||||
|
var restoreChannel = null;
|
||||||
|
var hadCurrentSelection = false;
|
||||||
|
var hasSelection = false;
|
||||||
|
if (channelName) {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
doc.selection.bounds;
|
||||||
|
hadCurrentSelection = true;
|
||||||
|
restoreChannel = doc.channels.add();
|
||||||
|
restoreChannel.name = "AI Edit Restore " + new Date().getTime();
|
||||||
|
doc.selection.store(restoreChannel);
|
||||||
|
} catch (noCurrentSelection) {}
|
||||||
|
|
||||||
|
selectionChannel = doc.channels.getByName(channelName);
|
||||||
|
doc.selection.load(selectionChannel);
|
||||||
|
hasSelection = true;
|
||||||
|
} catch (channelError) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var place = new ActionDescriptor();
|
||||||
|
place.putPath(charIDToTypeID("null"), file);
|
||||||
|
place.putEnumerated(charIDToTypeID("FTcs"), charIDToTypeID("QCSt"), charIDToTypeID("Qcsa"));
|
||||||
|
|
||||||
|
var offset = new ActionDescriptor();
|
||||||
|
offset.putUnitDouble(charIDToTypeID("Hrzn"), charIDToTypeID("#Pxl"), 0);
|
||||||
|
offset.putUnitDouble(charIDToTypeID("Vrtc"), charIDToTypeID("#Pxl"), 0);
|
||||||
|
place.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), offset);
|
||||||
|
|
||||||
|
place.putBoolean(stringIDToTypeID("linked"), false);
|
||||||
|
executeAction(charIDToTypeID("Plc "), place, DialogModes.NO);
|
||||||
|
|
||||||
|
var placedLayer = app.activeDocument.activeLayer;
|
||||||
|
if (layerName) placedLayer.name = layerName;
|
||||||
|
|
||||||
|
if (boundsText) {
|
||||||
|
var boundsValues = boundsText.split(",");
|
||||||
|
if (boundsValues.length === 4) {
|
||||||
|
var targetLeft = Number(boundsValues[0]);
|
||||||
|
var targetTop = Number(boundsValues[1]);
|
||||||
|
var targetWidth = Number(boundsValues[2]);
|
||||||
|
var targetHeight = Number(boundsValues[3]);
|
||||||
|
var placedBounds = placedLayer.bounds;
|
||||||
|
var placedWidth = Math.max(1, placedBounds[2].as("px") - placedBounds[0].as("px"));
|
||||||
|
var placedHeight = Math.max(1, placedBounds[3].as("px") - placedBounds[1].as("px"));
|
||||||
|
placedLayer.resize(
|
||||||
|
targetWidth / placedWidth * 100,
|
||||||
|
targetHeight / placedHeight * 100,
|
||||||
|
AnchorPosition.TOPLEFT
|
||||||
|
);
|
||||||
|
placedBounds = placedLayer.bounds;
|
||||||
|
placedLayer.translate(
|
||||||
|
targetLeft - placedBounds[0].as("px"),
|
||||||
|
targetTop - placedBounds[1].as("px")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.activeDocument.activeLayer = placedLayer;
|
||||||
|
aiEditAddLayerMask(hasSelection);
|
||||||
|
|
||||||
|
if (selectionChannel) {
|
||||||
|
if (hadCurrentSelection && restoreChannel) doc.selection.load(restoreChannel);
|
||||||
|
else doc.selection.deselect();
|
||||||
|
if (restoreChannel) restoreChannel.remove();
|
||||||
|
}
|
||||||
|
aiEditShowComposite(doc);
|
||||||
|
|
||||||
|
return "ok";
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
aiEditShowComposite(app.activeDocument);
|
||||||
|
} catch (restoreViewError) {}
|
||||||
|
return "error:" + e.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 568 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 535 B |
Binary file not shown.
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="styles/panel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Direction Approval
|
||||||
|
|
||||||
|
- User instruction: "请不要修改样式,必须严格按照我的设置还原"
|
||||||
|
- Approved source: `C:/Users/Administrator/AppData/Local/Temp/codex-clipboard-fd114ed3-645d-43e5-9cba-821120aa02cf.png`
|
||||||
|
- Decision: Skip design variations and reproduce the supplied two-page UI directly.
|
||||||
|
- Scope: Photoshop UXP panel in `D:/Software/Adobe/uxp/ai-edit`.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "com.aiedit.panel",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"watch": "nodemon -w src -e js,jsx,json,css,html -w webpack.config.js -w cep -x npm run build",
|
||||||
|
"build": "webpack --mode development",
|
||||||
|
"cep:setup": "node scripts/setup-cep.js"
|
||||||
|
},
|
||||||
|
"author": "Adobe Inc",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.8.7",
|
||||||
|
"@babel/plugin-proposal-object-rest-spread": "^7.8.3",
|
||||||
|
"@babel/plugin-syntax-class-properties": "^7.10.4",
|
||||||
|
"@babel/plugin-transform-react-jsx": "^7.8.3",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"babel-loader": "^8.0.6",
|
||||||
|
"copy-webpack-plugin": "^5.0.3",
|
||||||
|
"css-loader": "^6.11.0",
|
||||||
|
"mini-css-extract-plugin": "^2.10.2",
|
||||||
|
"nodemon": "^2.0.7",
|
||||||
|
"postcss": "^8.5.25",
|
||||||
|
"postcss-loader": "^8.2.1",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"webpack": "^5.88.1",
|
||||||
|
"webpack-cli": "^5.1.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^16.8.6",
|
||||||
|
"react-dom": "^16.8.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
nodemon: true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Enables CEP debug mode and links dist/ into the Adobe extensions folder.
|
||||||
|
// Run once per machine: npm run cep:setup
|
||||||
|
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const CSXS_VERSIONS = [9, 10, 11, 12];
|
||||||
|
const EXTENSION_NAME = "com.aiedit.panel";
|
||||||
|
const dist = path.resolve(__dirname, "..", "dist");
|
||||||
|
|
||||||
|
function enableDebugMode() {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
for (const version of CSXS_VERSIONS) {
|
||||||
|
execFileSync("reg", [
|
||||||
|
"add", `HKCU\\Software\\Adobe\\CSXS.${version}`,
|
||||||
|
"/v", "PlayerDebugMode", "/t", "REG_SZ", "/d", "1", "/f"
|
||||||
|
], { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const version of CSXS_VERSIONS) {
|
||||||
|
execFileSync("defaults", [
|
||||||
|
"write", `com.adobe.CSXS.${version}`, "PlayerDebugMode", "1"
|
||||||
|
], { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`PlayerDebugMode enabled for CSXS ${CSXS_VERSIONS.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionsFolder() {
|
||||||
|
return process.platform === "win32"
|
||||||
|
? path.join(process.env.APPDATA, "Adobe", "CEP", "extensions")
|
||||||
|
: path.join(os.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkExtension() {
|
||||||
|
const root = extensionsFolder();
|
||||||
|
fs.mkdirSync(root, { recursive: true });
|
||||||
|
|
||||||
|
const target = path.join(root, EXTENSION_NAME);
|
||||||
|
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.isSymbolicLink()) {
|
||||||
|
console.error(`${target} exists and is not a link. Remove it manually, then rerun.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
fs.unlinkSync(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Junctions avoid needing administrator rights on Windows.
|
||||||
|
fs.symlinkSync(dist, target, process.platform === "win32" ? "junction" : "dir");
|
||||||
|
console.log(`Linked ${target} -> ${dist}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(dist)) {
|
||||||
|
console.error("dist/ not found. Run `npm run build` first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableDebugMode();
|
||||||
|
linkExtension();
|
||||||
|
console.log("Restart Photoshop, then open Plugins > AI Edit.");
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
// Bridge between the panel UI and the Photoshop host / Node runtime.
|
||||||
|
|
||||||
|
const HOST_ERRORS = {
|
||||||
|
"missing-file": "临时文件不存在",
|
||||||
|
"missing-document": "请先打开一个 Photoshop 文档"
|
||||||
|
};
|
||||||
|
|
||||||
|
function nodeRequire(id) {
|
||||||
|
const resolver = window.cep_node ? window.cep_node.require : window.require;
|
||||||
|
if (!resolver) throw new Error("Node 运行时不可用,请确认面板启用了 --enable-nodejs");
|
||||||
|
return resolver(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalScript only transports ASCII reliably, and host.jsx is ASCII-only, so any
|
||||||
|
// localized string has to cross the bridge as a \uXXXX escape.
|
||||||
|
function toExtendScriptString(value) {
|
||||||
|
return JSON.stringify(String(value)).replace(
|
||||||
|
/[^\x00-\x7F]/g,
|
||||||
|
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalScript(script) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cep = window.__adobe_cep__;
|
||||||
|
if (!cep) {
|
||||||
|
reject(new Error("CEP 环境不可用"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cep.evalScript(script, (result) => {
|
||||||
|
if (result === "EvalScript error.") reject(new Error("ExtendScript 执行失败"));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function call(fn, ...args) {
|
||||||
|
return evalScript(`${fn}(${args.map(toExtendScriptString).join(", ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasActiveDocument() {
|
||||||
|
try {
|
||||||
|
return (await evalScript("aiEditHasDocument()")) === "yes";
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGenerationSize() {
|
||||||
|
const result = await evalScript("aiEditGetGenerationSize()");
|
||||||
|
return result && /^\d+x\d+$/.test(result) ? result : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureSelectionReference() {
|
||||||
|
const result = await evalScript("aiEditCaptureSelection()");
|
||||||
|
if (!result || result === "none") return null;
|
||||||
|
if (result.indexOf("error:") === 0) {
|
||||||
|
throw new Error(HOST_ERRORS[result.slice(6)] || result.slice(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = result.slice(3).split("|");
|
||||||
|
if (parts.length < 3) throw new Error("无法读取 Photoshop 选区");
|
||||||
|
return {
|
||||||
|
channelName: parts[0],
|
||||||
|
filePath: parts[1],
|
||||||
|
bounds: parts[2]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickReferenceImages() {
|
||||||
|
const result = await call("aiEditPickImages", "选择参考图", "图片:*.png;*.jpg;*.jpeg;*.webp");
|
||||||
|
return result ? result.split("\n").filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns null when the user cancels, so an empty string stays a real answer.
|
||||||
|
export async function openPromptDialog(initial) {
|
||||||
|
const result = await call(
|
||||||
|
"aiEditPromptDialog",
|
||||||
|
"输入提示词",
|
||||||
|
"在此输入描述提示词,支持中文输入法:",
|
||||||
|
"确定",
|
||||||
|
"取消",
|
||||||
|
initial || ""
|
||||||
|
);
|
||||||
|
if (!result || !result.startsWith("ok:")) return null;
|
||||||
|
|
||||||
|
// ExtendScript sends escape()-encoded text; unescape is the only decoder
|
||||||
|
// that understands its %uXXXX form.
|
||||||
|
return unescape(result.slice(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requests go through Node rather than fetch: the panel talks to whatever
|
||||||
|
// endpoint the user configures, and no CORS headers are expected back.
|
||||||
|
function request(url, { method = "GET", headers = {}, body } = {}, redirects = 5) {
|
||||||
|
const { Buffer } = nodeRequire("buffer");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let target;
|
||||||
|
try {
|
||||||
|
target = new URL(url);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`地址无效:${url}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||||
|
reject(new Error(`不支持的协议:${target.protocol}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = nodeRequire(target.protocol === "https:" ? "https" : "http");
|
||||||
|
// Older CEP runtimes ship a Node that cannot take a URL object here.
|
||||||
|
const options = {
|
||||||
|
protocol: target.protocol,
|
||||||
|
hostname: target.hostname,
|
||||||
|
port: target.port || undefined,
|
||||||
|
path: `${target.pathname}${target.search}`,
|
||||||
|
method,
|
||||||
|
headers
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = client.request(options, (res) => {
|
||||||
|
const { statusCode, headers: resHeaders } = res;
|
||||||
|
|
||||||
|
if (statusCode >= 300 && statusCode < 400 && resHeaders.location) {
|
||||||
|
res.resume();
|
||||||
|
if (redirects <= 0) {
|
||||||
|
reject(new Error("重定向次数过多"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new URL(resHeaders.location, target);
|
||||||
|
// Never carry the API key to a host the user did not configure.
|
||||||
|
const nextHeaders = { ...headers };
|
||||||
|
if (next.origin !== target.origin) delete nextHeaders.Authorization;
|
||||||
|
|
||||||
|
request(next.toString(), { method, headers: nextHeaders, body }, redirects - 1)
|
||||||
|
.then(resolve, reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
res.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
res.on("end", () => resolve({ statusCode, buffer: Buffer.concat(chunks) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", (error) => reject(new Error(error.message || "网络请求失败")));
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postJson(url, { apiKey, body }) {
|
||||||
|
const { Buffer } = nodeRequire("buffer");
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
const { statusCode, buffer } = await request(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(payload),
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
return { statusCode, text: buffer.toString("utf8") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postMultipart(url, { apiKey, fields, files }) {
|
||||||
|
const { Buffer } = nodeRequire("buffer");
|
||||||
|
const boundary = `----AIEditBoundary${Date.now().toString(16)}`;
|
||||||
|
const chunks = [];
|
||||||
|
const addText = (value) => chunks.push(Buffer.from(value, "utf8"));
|
||||||
|
|
||||||
|
Object.keys(fields || {}).forEach((name) => {
|
||||||
|
addText(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
|
||||||
|
`${String(fields[name])}\r\n`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
(files || []).forEach((file) => {
|
||||||
|
const match = String(file.dataUrl).match(/^data:([^;]+);base64,(.*)$/);
|
||||||
|
if (!match) throw new Error("参考图数据格式无效");
|
||||||
|
|
||||||
|
addText(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\n` +
|
||||||
|
`Content-Type: ${match[1]}\r\n\r\n`
|
||||||
|
);
|
||||||
|
chunks.push(Buffer.from(match[2], "base64"));
|
||||||
|
addText("\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
addText(`--${boundary}--\r\n`);
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
const { statusCode, buffer } = await request(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
"Content-Length": body.length,
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
return { statusCode, text: buffer.toString("utf8") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadImage(url) {
|
||||||
|
const { statusCode, buffer } = await request(url);
|
||||||
|
if (statusCode < 200 || statusCode >= 300) throw new Error(`图片下载失败:${statusCode}`);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileName(filePath) {
|
||||||
|
return String(filePath).split(/[\\/]/).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTempImage(buffer, index) {
|
||||||
|
const fs = nodeRequire("fs");
|
||||||
|
const os = nodeRequire("os");
|
||||||
|
const path = nodeRequire("path");
|
||||||
|
const { Buffer } = nodeRequire("buffer");
|
||||||
|
|
||||||
|
const folder = path.join(os.tmpdir(), "ai-edit");
|
||||||
|
if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });
|
||||||
|
|
||||||
|
const target = path.join(folder, `ai-edit-${Date.now()}-${index}.png`);
|
||||||
|
fs.writeFileSync(target, Buffer.from(buffer));
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileAsDataUrl(filePath) {
|
||||||
|
const fs = nodeRequire("fs");
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
const extension = String(filePath).split(".").pop().toLowerCase();
|
||||||
|
const mime = {
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
webp: "image/webp"
|
||||||
|
}[extension] || "image/png";
|
||||||
|
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function placeImageFile(filePath, layerName, channelName, bounds) {
|
||||||
|
const result = await call(
|
||||||
|
"aiEditPlaceImage",
|
||||||
|
filePath,
|
||||||
|
layerName,
|
||||||
|
channelName || "",
|
||||||
|
bounds || ""
|
||||||
|
);
|
||||||
|
if (result === "ok") return;
|
||||||
|
|
||||||
|
const detail = String(result || "").replace(/^error:/, "");
|
||||||
|
throw new Error(HOST_ERRORS[detail] || detail || "图片置入失败");
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Element-level styles Tailwind classes cannot reach (html/body/#root). */
|
||||||
|
@layer base {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
@apply h-full w-full min-w-0 overflow-hidden bg-[#202124] p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply m-0 text-[12px] text-[#f2f3f5];
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
@apply h-full w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
[contenteditable]:focus-visible {
|
||||||
|
@apply outline outline-2 outline-offset-1 outline-[#6ba5ff];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ===================================
|
||||||
|
全局隐藏滚动条(保留滚动能力)
|
||||||
|
CEP / Chromium
|
||||||
|
=================================== */
|
||||||
|
|
||||||
|
/* Chrome / Chromium */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* IE / Edge旧版 */
|
||||||
|
* {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
|
import "./index.css";
|
||||||
|
import { ImageGenerator } from "./panels/ImageGenerator.jsx";
|
||||||
|
|
||||||
|
ReactDOM.render(<ImageGenerator />, document.getElementById("root"));
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
downloadImage,
|
||||||
|
captureSelectionReference,
|
||||||
|
fileName,
|
||||||
|
getGenerationSize,
|
||||||
|
hasActiveDocument,
|
||||||
|
openPromptDialog,
|
||||||
|
pickReferenceImages,
|
||||||
|
placeImageFile,
|
||||||
|
postMultipart,
|
||||||
|
postJson,
|
||||||
|
readFileAsDataUrl,
|
||||||
|
writeTempImage,
|
||||||
|
} from "../cep.js";
|
||||||
|
|
||||||
|
const SETTINGS_KEY = "ai-edit.settings.v3";
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
endpoint: "",
|
||||||
|
apiKey: "",
|
||||||
|
model: "gpt-image-2",
|
||||||
|
size: "1024x1024",
|
||||||
|
quality: "low",
|
||||||
|
background: "auto",
|
||||||
|
count: 1,
|
||||||
|
taskMode: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODEL_OPTIONS = [
|
||||||
|
"gpt-image-2",
|
||||||
|
"gpt-image-1",
|
||||||
|
"gpt-image-1.5",
|
||||||
|
"gemini-3.1-flash-lite-image",
|
||||||
|
].map((model) => ({ label: model, value: model }));
|
||||||
|
const QUALITY_OPTIONS = [
|
||||||
|
{ label: "1K", value: "low" },
|
||||||
|
{ label: "2K", value: "medium" },
|
||||||
|
{ label: "3K", value: "high" },
|
||||||
|
];
|
||||||
|
const ICON_PATHS = {
|
||||||
|
help: [
|
||||||
|
"M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4",
|
||||||
|
"M12 18h.01",
|
||||||
|
"M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z",
|
||||||
|
],
|
||||||
|
settings: [
|
||||||
|
"M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z",
|
||||||
|
"M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34A1.7 1.7 0 0 0 14 20.92V21h-4v-.08A1.7 1.7 0 0 0 9 19.36a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.63 15 1.7 1.7 0 0 0 3.08 14H3v-4h.08A1.7 1.7 0 0 0 4.64 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.63 1.7 1.7 0 0 0 10 3.08V3h4v.08A1.7 1.7 0 0 0 15 4.64a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.37 9 1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15Z",
|
||||||
|
],
|
||||||
|
refresh: [
|
||||||
|
"M20 7v5h-5",
|
||||||
|
"M4 17v-5h5",
|
||||||
|
"M6.1 9a7 7 0 0 1 11.6-2.6L20 9",
|
||||||
|
"M4 15l2.3 2.6A7 7 0 0 0 18 15",
|
||||||
|
],
|
||||||
|
expand: ["M8 3H3v5", "M16 3h5v5", "M8 21H3v-5", "M16 21h5v-5"],
|
||||||
|
upload: [
|
||||||
|
"M12 16V5",
|
||||||
|
"m7.5 9.5 4.5-4.5 4.5 4.5",
|
||||||
|
"M20 16.5a4.5 4.5 0 0 1-4.5 4.5h-8A4.5 4.5 0 0 1 7 12.03 6 6 0 0 1 18.55 10 3.5 3.5 0 0 1 20 16.5Z",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SELECT_CLASS = "block w-full min-w-0 flex-auto";
|
||||||
|
const SELECT_FIELD =
|
||||||
|
"h-[33px] w-full appearance-none rounded-[6px] border-0 bg-[#2f2f2f] pl-[10px] pr-[34px] text-[13px] font-semibold text-[#f4f4f4] hover:bg-[#3a3a3a]";
|
||||||
|
const MINI_HELP =
|
||||||
|
"ml-[3px] inline-flex h-[13px] w-[13px] items-center justify-center rounded-full border border-white text-[9px] font-bold leading-[13px] text-white";
|
||||||
|
const GROUP_LABEL =
|
||||||
|
"flex h-[20px] items-center text-[12px] font-bold text-[#eeeeee]";
|
||||||
|
const PRIMARY_BUTTON =
|
||||||
|
"flex h-[28px] w-full items-center justify-center rounded-[5px] border-0 bg-accent px-[10px] text-[12px] font-bold text-white hover:bg-[#3d88fa]";
|
||||||
|
const TOOL_BUTTON =
|
||||||
|
"flex w-[34px] cursor-pointer flex-col items-center justify-center border border-[#6e6e6e] bg-[#3d3d3d] p-0 text-[#ededed] hover:bg-[#474747]";
|
||||||
|
|
||||||
|
const Icon = ({ name, size = 18, stroke = "#f4f4f4" }) => (
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none block flex-none"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{ICON_PATHS[name].map((path, index) => (
|
||||||
|
<path d={path} key={`${name}-${index}`} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
return saved
|
||||||
|
? { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }
|
||||||
|
: DEFAULT_SETTINGS;
|
||||||
|
} catch (error) {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanEndpoint(endpoint, operation = "generations") {
|
||||||
|
const trimmed = endpoint.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const suffix = `/images/${operation}`;
|
||||||
|
if (trimmed.endsWith("/images/generations"))
|
||||||
|
return trimmed.replace(/\/images\/generations$/, suffix);
|
||||||
|
if (trimmed.endsWith("/images/edits"))
|
||||||
|
return trimmed.replace(/\/images\/edits$/, suffix);
|
||||||
|
return `${trimmed.replace(/\/$/, "")}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImagePayload(result) {
|
||||||
|
const candidates = [];
|
||||||
|
if (Array.isArray(result && result.data)) candidates.push(...result.data);
|
||||||
|
if (Array.isArray(result && result.output)) candidates.push(...result.output);
|
||||||
|
if (Array.isArray(result && result.images)) candidates.push(...result.images);
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.map((item) => {
|
||||||
|
if (!item) return null;
|
||||||
|
if (item.b64_json) return { type: "base64", value: item.b64_json };
|
||||||
|
if (item.image) return { type: "base64", value: item.image };
|
||||||
|
if (item.url) return { type: "url", value: item.url };
|
||||||
|
if (typeof item === "string" && item.startsWith("http"))
|
||||||
|
return { type: "url", value: item };
|
||||||
|
if (typeof item === "string") return { type: "base64", value: item };
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToArrayBuffer(base64) {
|
||||||
|
const binary = atob(base64.replace(/^data:image\/\w+;base64,/, ""));
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1)
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payloadToBuffer(payload) {
|
||||||
|
if (payload.type === "url") return downloadImage(payload.value);
|
||||||
|
return base64ToArrayBuffer(payload.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadToSrc(payload) {
|
||||||
|
if (payload.type === "url") return payload.value;
|
||||||
|
if (payload.value.startsWith("data:")) return payload.value;
|
||||||
|
return `data:image/png;base64,${payload.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentedControl({ options, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[27px] overflow-hidden rounded-[5px] bg-[#2f2f2f]">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
className={`h-[27px] min-w-0 flex-1 rounded-none border-0 px-[4px] text-[12px] font-semibold ${
|
||||||
|
value === option.value
|
||||||
|
? "bg-[#1688f7] text-white"
|
||||||
|
: "bg-transparent text-[#c5c5c5]"
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageGenerator = () => {
|
||||||
|
const [settings, setSettings] = useState(loadSettings);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [referenceFiles, setReferenceFiles] = useState([]);
|
||||||
|
const [activeView, setActiveView] = useState("generate");
|
||||||
|
const [isPromptFocused, setIsPromptFocused] = useState(false);
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateSetting = (key, value) =>
|
||||||
|
setSettings((current) => ({ ...current, [key]: value }));
|
||||||
|
const canGenerate = useMemo(
|
||||||
|
() =>
|
||||||
|
Boolean(settings.endpoint && settings.apiKey && prompt.trim() && !isBusy),
|
||||||
|
[settings, prompt, isBusy],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The editor is contentEditable, so the dialog result has to be written in directly.
|
||||||
|
const editPromptInPhotoshop = async () => {
|
||||||
|
const text = await openPromptDialog(prompt);
|
||||||
|
if (text === null) return;
|
||||||
|
setPrompt(text);
|
||||||
|
if (editorRef.current) editorRef.current.textContent = text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseReferenceImages = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await pickReferenceImages();
|
||||||
|
setReferenceFiles(selected);
|
||||||
|
setStatus(selected.length ? `已选择 ${selected.length} 张参考图` : "");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
if (!canGenerate) {
|
||||||
|
setStatus(
|
||||||
|
settings.endpoint && settings.apiKey
|
||||||
|
? "请输入描述提示词"
|
||||||
|
: "请先在设置中填写 API 地址和 API Key",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBusy(true);
|
||||||
|
setStatus("正在生成...");
|
||||||
|
try {
|
||||||
|
const selectionReference = await captureSelectionReference();
|
||||||
|
const isEdit = Boolean(selectionReference);
|
||||||
|
const generationSize = await getGenerationSize();
|
||||||
|
if (isEdit) setStatus("正在编辑选区...");
|
||||||
|
const referenceImages = selectionReference
|
||||||
|
? [selectionReference.filePath, ...referenceFiles].map(readFileAsDataUrl)
|
||||||
|
: referenceFiles.map(readFileAsDataUrl);
|
||||||
|
const requestFields = {
|
||||||
|
model: settings.model.trim(),
|
||||||
|
prompt: prompt.trim(),
|
||||||
|
n: Number(settings.count) || 1,
|
||||||
|
size: generationSize || settings.size,
|
||||||
|
quality: settings.quality,
|
||||||
|
response_format: "b64_json",
|
||||||
|
...(settings.background !== "auto"
|
||||||
|
? { background: settings.background }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const requestResult = isEdit
|
||||||
|
? await postMultipart(
|
||||||
|
cleanEndpoint(settings.endpoint, "edits"),
|
||||||
|
{
|
||||||
|
apiKey: settings.apiKey.trim(),
|
||||||
|
fields: requestFields,
|
||||||
|
files: referenceImages.map((dataUrl, index) => ({
|
||||||
|
fieldName: referenceImages.length === 1 ? "image" : "image[]",
|
||||||
|
fileName: `ai-edit-reference-${index + 1}.png`,
|
||||||
|
dataUrl,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: await postJson(cleanEndpoint(settings.endpoint, "generations"), {
|
||||||
|
apiKey: settings.apiKey.trim(),
|
||||||
|
body: {
|
||||||
|
...requestFields,
|
||||||
|
...(referenceImages.length
|
||||||
|
? { images: referenceImages, image: referenceImages[0] }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { statusCode, text: resultText } = requestResult;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(resultText);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(resultText || "接口返回不是 JSON");
|
||||||
|
}
|
||||||
|
if (statusCode < 200 || statusCode >= 300)
|
||||||
|
throw new Error(
|
||||||
|
(result.error && (result.error.message || result.error.code)) ||
|
||||||
|
`接口请求失败:${statusCode}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payloads = extractImagePayload(result);
|
||||||
|
if (!payloads.length) throw new Error("接口响应里没有找到图片数据");
|
||||||
|
|
||||||
|
setResults((current) =>
|
||||||
|
[
|
||||||
|
...payloads.map((payload) => ({
|
||||||
|
src: payloadToSrc(payload),
|
||||||
|
channelName: selectionReference && selectionReference.channelName,
|
||||||
|
bounds: selectionReference && selectionReference.bounds,
|
||||||
|
})),
|
||||||
|
...current,
|
||||||
|
].slice(0, 12),
|
||||||
|
);
|
||||||
|
setStatus(`完成:已生成 ${payloads.length} 张,请点击缩略图置入`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message || String(error));
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeResult = async (result, index) => {
|
||||||
|
if (!(await hasActiveDocument())) {
|
||||||
|
setStatus("请先在 Photoshop 中打开一个文档");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("正在置入...");
|
||||||
|
try {
|
||||||
|
const payload = result.src.startsWith("http")
|
||||||
|
? { type: "url", value: result.src }
|
||||||
|
: { type: "base64", value: result.src };
|
||||||
|
const buffer = await payloadToBuffer(payload);
|
||||||
|
const file = await writeTempImage(buffer, index + 1);
|
||||||
|
await placeImageFile(
|
||||||
|
file,
|
||||||
|
`AI 生图 - ${index + 1}`,
|
||||||
|
result.channelName,
|
||||||
|
result.bounds,
|
||||||
|
);
|
||||||
|
setStatus("已置入当前文档");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message || String(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = () => {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
setStatus("设置已保存");
|
||||||
|
setActiveView("generate");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative h-screen min-h-[520px] w-full min-w-[300px] overflow-y-auto overflow-x-hidden bg-[#4a4a4a] px-[19px] pb-[28px] pt-[15px] text-[#eeeeee] narrow:px-[14px]">
|
||||||
|
<header className="flex h-[47px] items-start justify-between">
|
||||||
|
<div className="flex items-center pt-[1px] text-white">
|
||||||
|
<span
|
||||||
|
className="relative mr-[8px] block h-[17px] w-[17px] rounded-t-[9px] rounded-b-[3px] border-2 border-b-0 border-white before:absolute before:bottom-[-2px] before:left-[-4px] before:right-[-4px] before:h-[2px] before:rounded-[2px] before:bg-white before:content-[''] after:absolute after:bottom-[-6px] after:left-[5px] after:h-[3px] after:w-[5px] after:rounded-b-[4px] after:bg-white after:content-['']"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<strong className="text-[14px] font-bold leading-[25px]">
|
||||||
|
AI-Edite
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center pt-[6px]">
|
||||||
|
<div
|
||||||
|
className="ml-[7px] flex h-[24px] w-[26px] cursor-pointer items-center justify-center rounded-[4px] bg-transparent text-[#f4f4f4] hover:bg-white/10"
|
||||||
|
title="帮助"
|
||||||
|
onClick={() => setStatus("请先配置 API,再输入提示词生成图片")}
|
||||||
|
>
|
||||||
|
<Icon name="help" size={17} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="ml-[7px] flex h-[24px] w-[26px] cursor-pointer items-center justify-center rounded-[4px] bg-transparent text-[#f4f4f4] hover:bg-white/10"
|
||||||
|
title="设置"
|
||||||
|
onClick={() =>
|
||||||
|
setActiveView(activeView === "settings" ? "generate" : "settings")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="settings" size={17} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{activeView === "settings" ? (
|
||||||
|
<section className="w-full pt-[5px]">
|
||||||
|
<label className="mb-[16px] block">
|
||||||
|
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
|
||||||
|
API地址
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="h-[34px] w-full rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee] placeholder:text-[#898989]"
|
||||||
|
value={settings.endpoint}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSetting("endpoint", event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="请输入API地址"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="mb-[16px] block">
|
||||||
|
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
|
||||||
|
API Key
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="h-[34px] w-full rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee] placeholder:text-[#898989]"
|
||||||
|
type="password"
|
||||||
|
value={settings.apiKey}
|
||||||
|
onChange={(event) => updateSetting("apiKey", event.target.value)}
|
||||||
|
placeholder="请输入API-Key"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="mb-[16px] block">
|
||||||
|
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
|
||||||
|
模型
|
||||||
|
</span>
|
||||||
|
<span className="relative block w-full">
|
||||||
|
<select
|
||||||
|
className="h-[34px] w-full appearance-none rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee]"
|
||||||
|
value={settings.model}
|
||||||
|
onChange={(event) => updateSetting("model", event.target.value)}
|
||||||
|
>
|
||||||
|
{MODEL_OPTIONS.map((model) => (
|
||||||
|
<option key={model.value} value={model.value}>
|
||||||
|
{model.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className={`${PRIMARY_BUTTON} mt-[22px]`}
|
||||||
|
onClick={saveSettings}
|
||||||
|
>
|
||||||
|
保存设置
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="w-full">
|
||||||
|
<div className="mx-[2px] mb-[10px] flex h-[36px] items-center">
|
||||||
|
<span className="flex-[0_0_39px] text-[13px] font-bold">模型</span>
|
||||||
|
<span className={`relative ${SELECT_CLASS}`}>
|
||||||
|
<select
|
||||||
|
className={SELECT_FIELD}
|
||||||
|
value={settings.model}
|
||||||
|
onChange={(event) => updateSetting("model", event.target.value)}
|
||||||
|
>
|
||||||
|
{MODEL_OPTIONS.map((model) => (
|
||||||
|
<option key={model.value} value={model.value}>
|
||||||
|
{model.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="ml-[9px] flex h-[33px] flex-[0_0_41px] cursor-pointer items-center justify-center rounded-[5px] border-0 bg-[#2f2f2f] p-0 text-white hover:bg-[#3a3a3a]"
|
||||||
|
title="刷新模型"
|
||||||
|
onClick={() => setStatus("模型列表已刷新")}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size={17} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative mb-[17px] h-[116px] overflow-y-auto rounded-[5px] bg-[#2f2f2f] p-[5px]"
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
onInput={(event) =>
|
||||||
|
setPrompt(event.currentTarget.textContent || "")
|
||||||
|
}
|
||||||
|
onFocus={() => setIsPromptFocused(true)}
|
||||||
|
onBlur={() => setIsPromptFocused(false)}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
>
|
||||||
|
{!prompt && !isPromptFocused && (
|
||||||
|
<span className="pointer-events-none absolute left-[9px] top-[8px] text-[13px] font-semibold leading-[18px] text-[#a8a8a8]">
|
||||||
|
描述提示词...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mb-[13px] flex h-[135px] w-full cursor-pointer flex-col items-center justify-center rounded-[5px] border-0 bg-[#2f2f2f] p-[12px] text-center text-[#e7e7e7] hover:bg-[#3a3a3a]"
|
||||||
|
onClick={chooseReferenceImages}
|
||||||
|
>
|
||||||
|
<div className="mb-[10px] flex h-[30px] w-full items-center justify-center">
|
||||||
|
<Icon name="upload" size={30} stroke="#b0b0b0" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-[13px] font-bold leading-[19px] text-[#ececec]">
|
||||||
|
{referenceFiles.length
|
||||||
|
? `已上传 ${referenceFiles.length} 张参考图`
|
||||||
|
: "点击或者拖拽上传参考图"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[8px] w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-[10px] leading-[14px] text-[#b6b6b6]">
|
||||||
|
{referenceFiles.length
|
||||||
|
? referenceFiles.map(fileName).join("、")
|
||||||
|
: "(画布选区为图1,上传图依次为图2、图3...)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-[4px] -mr-[15px] flex flex-wrap narrow:-mr-[9px]">
|
||||||
|
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||||
|
<span className={GROUP_LABEL}>数量</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={settings.count}
|
||||||
|
onChange={(value) => updateSetting("count", value)}
|
||||||
|
options={[
|
||||||
|
{ label: "1张", value: 1 },
|
||||||
|
{ label: "2张", value: 2 },
|
||||||
|
{ label: "3张", value: 3 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||||
|
<span className={GROUP_LABEL}>画质</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={settings.quality}
|
||||||
|
onChange={(value) => updateSetting("quality", value)}
|
||||||
|
options={QUALITY_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||||
|
<span className={GROUP_LABEL}>背景</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={settings.background}
|
||||||
|
onChange={(value) => updateSetting("background", value)}
|
||||||
|
options={[
|
||||||
|
{ label: "默认", value: "auto" },
|
||||||
|
{ label: "透明底", value: "transparent" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||||
|
<span className={GROUP_LABEL}>
|
||||||
|
生图模式 <span className={MINI_HELP}>?</span>
|
||||||
|
</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={settings.taskMode}
|
||||||
|
onChange={(value) => updateSetting("taskMode", value)}
|
||||||
|
options={[
|
||||||
|
{ label: "默认", value: "default" },
|
||||||
|
{ label: "多任务", value: "multi" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={PRIMARY_BUTTON} onClick={generate}>
|
||||||
|
{isBusy ? "生成中..." : "立即生成"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="-mr-[6px] mt-[14px] flex flex-wrap">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<button
|
||||||
|
className="mb-[6px] mr-[6px] block h-[84px] min-w-0 flex-[0_0_calc(25%_-_6px)] overflow-hidden rounded-[3px] border-0 bg-[#2f2f2f] p-0"
|
||||||
|
key={`${index}-${result.src.slice(-16)}`}
|
||||||
|
title="点击置入当前文档"
|
||||||
|
onClick={() => placeResult(result, index)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="block h-full w-full object-cover"
|
||||||
|
src={result.src}
|
||||||
|
alt={`生成结果 ${index + 1}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-[7px] min-h-[14px] overflow-hidden text-ellipsis whitespace-nowrap text-center text-[9px] leading-[14px] text-[#e1e1e1]"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: ["./src/**/*.{js,jsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
accent: "#2f7cf6"
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
// Rounded stack: first installed font wins, falling back to the
|
||||||
|
// locally available Maple Mono NF CN for Chinese.
|
||||||
|
sans: [
|
||||||
|
"MiSans", "HarmonyOS Sans SC", "Nunito", "Quicksand", "Varela Round",
|
||||||
|
"Maple Mono NF CN", "Microsoft YaHei UI", "Microsoft YaHei"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
screens: {
|
||||||
|
narrow: { max: "340px" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const CopyPlugin = require("copy-webpack-plugin");
|
||||||
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/index.jsx',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: 'index.js'
|
||||||
|
},
|
||||||
|
target: 'web',
|
||||||
|
devtool: 'eval-cheap-source-map',
|
||||||
|
resolve: {
|
||||||
|
extensions: [".js", ".jsx"]
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.jsx?$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
loader: "babel-loader",
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
"@babel/transform-react-jsx",
|
||||||
|
"@babel/proposal-object-rest-spread",
|
||||||
|
"@babel/plugin-syntax-class-properties",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new MiniCssExtractPlugin({ filename: 'styles/panel.css' }),
|
||||||
|
new CopyPlugin([
|
||||||
|
'cep'
|
||||||
|
], {
|
||||||
|
copyUnmodified: true
|
||||||
|
})
|
||||||
|
]
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user