Compare commits
33 Commits
0f60721162
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59abcb11b8 | ||
|
|
5c9887c669 | ||
|
|
a9dd7910a0 | ||
|
|
30010078b4 | ||
|
|
60dcb279ae | ||
|
|
117f27effc | ||
|
|
8721b7bc61 | ||
|
|
714969cfb9 | ||
|
|
5f5f6215b1 | ||
|
|
5d493b6ed5 | ||
|
|
90f6348e4d | ||
|
|
bc361cd011 | ||
|
|
a99fa16763 | ||
|
|
1b35cb6a44 | ||
|
|
731428fb44 | ||
|
|
1ec80a01cb | ||
|
|
51c3ea4bc1 | ||
|
|
5f8f29a7dc | ||
|
|
beb1e2b2fc | ||
|
|
0bdcd3036a | ||
|
|
e3a90d6186 | ||
|
|
ce98638e55 | ||
|
|
9e675dd804 | ||
|
|
379865a6aa | ||
|
|
9fb05e4e7e | ||
|
|
ff5f679c4a | ||
|
|
d67ec8c0ac | ||
|
|
b9d7ea0c04 | ||
|
|
6e6e92df53 | ||
|
|
02b0143186 | ||
|
|
3434927342 | ||
|
|
2af1ef9292 | ||
|
|
6715544952 |
69
.github/workflows/sonarqube.yml
vendored
Normal file
69
.github/workflows/sonarqube.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: SonarQube Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'zulu' # Alternative distribution options are available.
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x' # .NET 8 SDK supports building .NET Framework 4.7.2 projects
|
||||
|
||||
- name: Install SonarQube Cloud scanner
|
||||
run: |
|
||||
mkdir -p ~/.sonar/scanner
|
||||
dotnet tool install dotnet-sonarscanner --tool-path ~/.sonar/scanner
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore AIImages.sln --verbosity normal
|
||||
|
||||
- name: Build and analyze
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
echo "Starting SonarQube analysis..."
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Listing files:"
|
||||
ls -la
|
||||
echo "Starting SonarQube scanner..."
|
||||
|
||||
~/.sonar/scanner/dotnet-sonarscanner begin \
|
||||
/k:"AIImages" \
|
||||
/d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
|
||||
/d:sonar.host.url="${{ secrets.SONAR_HOST_URL }}" \
|
||||
/d:sonar.projectBaseDir="$(pwd)" \
|
||||
/d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" \
|
||||
/d:sonar.coverage.exclusions="**/obj/**,**/bin/**,**/Assemblies/**" \
|
||||
/d:sonar.exclusions="**/obj/**,**/bin/**,**/Assemblies/**,**/Migrations/**" \
|
||||
/d:sonar.cpd.exclusions="**/obj/**,**/bin/**" \
|
||||
/d:sonar.test.inclusions="**/*Tests.cs,**/*Test.cs" \
|
||||
/d:sonar.sources="Source" \
|
||||
/d:sonar.sourceEncoding=UTF-8
|
||||
|
||||
echo "Building solution..."
|
||||
dotnet build AIImages.sln --verbosity normal --no-incremental -c Release
|
||||
|
||||
echo "Running tests with coverage (if any)..."
|
||||
dotnet test AIImages.sln /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=./coverage/ /p:Exclude="[*.Tests]*" || echo "No tests found, skipping test execution"
|
||||
|
||||
echo "Ending SonarQube analysis..."
|
||||
~/.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Assemblies/StableDiffusionNet.Core.dll
Normal file
BIN
Assemblies/StableDiffusionNet.Core.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
111
Defs/ArtStyleDefs.xml
Normal file
111
Defs/ArtStyleDefs.xml
Normal file
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Defs>
|
||||
<!-- None/Custom Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_None</defName>
|
||||
<label>None (Custom)</label>
|
||||
<description>No predefined style - use only your custom prompts</description>
|
||||
<positivePrompt></positivePrompt>
|
||||
<negativePrompt></negativePrompt>
|
||||
<qualityTags></qualityTags>
|
||||
<addBaseQualityTags>false</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>false</addBaseNegativePrompts>
|
||||
<sortOrder>0</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Realistic Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_Realistic</defName>
|
||||
<label>Realistic</label>
|
||||
<description>Photorealistic style with high detail</description>
|
||||
<positivePrompt>photorealistic, hyperrealistic, realistic photo, photography</positivePrompt>
|
||||
<negativePrompt>cartoon, anime, painting, drawing, illustration</negativePrompt>
|
||||
<qualityTags>professional photography, 8k uhd, dslr, high quality, sharp focus</qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>10</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Semi-Realistic Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_SemiRealistic</defName>
|
||||
<label>Semi-Realistic</label>
|
||||
<description>Detailed illustration with realistic elements</description>
|
||||
<positivePrompt>semi-realistic, detailed illustration, realistic art</positivePrompt>
|
||||
<negativePrompt>cartoon, anime, painting, drawing, illustration</negativePrompt>
|
||||
<qualityTags>professional photography, 8k uhd, dslr, high quality, sharp focus</qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>20</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Anime Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_Anime</defName>
|
||||
<label>Anime</label>
|
||||
<description>Japanese anime/manga style</description>
|
||||
<positivePrompt>anime style, manga style, anime character</positivePrompt>
|
||||
<negativePrompt>realistic, photo, photography, 3d</negativePrompt>
|
||||
<qualityTags>anime masterpiece, high resolution, vibrant colors</qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>30</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Concept Art Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_ConceptArt</defName>
|
||||
<label>Concept Art</label>
|
||||
<description>Professional digital concept art</description>
|
||||
<positivePrompt>concept art, digital art, artstation, professional concept design</positivePrompt>
|
||||
<negativePrompt></negativePrompt>
|
||||
<qualityTags>trending on artstation, professional digital art</qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>40</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Digital Painting Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_DigitalPainting</defName>
|
||||
<label>Digital Painting</label>
|
||||
<description>Digital painting with brush strokes</description>
|
||||
<positivePrompt>digital painting, painterly, brush strokes, artistic</positivePrompt>
|
||||
<negativePrompt></negativePrompt>
|
||||
<qualityTags></qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>50</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Oil Painting Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_OilPainting</defName>
|
||||
<label>Oil Painting</label>
|
||||
<description>Traditional oil painting style</description>
|
||||
<positivePrompt>oil painting, traditional painting, canvas, fine art</positivePrompt>
|
||||
<negativePrompt></negativePrompt>
|
||||
<qualityTags></qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>60</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Sketch Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_Sketch</defName>
|
||||
<label>Sketch</label>
|
||||
<description>Pencil sketch or line art</description>
|
||||
<positivePrompt>pencil sketch, hand drawn, sketch art, line art</positivePrompt>
|
||||
<negativePrompt></negativePrompt>
|
||||
<qualityTags></qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>70</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
<!-- Cell Shaded Style -->
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_CellShaded</defName>
|
||||
<label>Cell Shaded</label>
|
||||
<description>Flat colors with toon shading</description>
|
||||
<positivePrompt>cell shaded, flat colors, toon shading, stylized</positivePrompt>
|
||||
<negativePrompt></negativePrompt>
|
||||
<qualityTags></qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>80</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
</Defs>
|
||||
78
Defs/ImageSizePresetDefs.xml
Normal file
78
Defs/ImageSizePresetDefs.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Defs>
|
||||
<!-- Square Sizes -->
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_512x512</defName>
|
||||
<label>512x512</label>
|
||||
<width>512</width>
|
||||
<height>512</height>
|
||||
<category>Square</category>
|
||||
<sortOrder>10</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_768x768</defName>
|
||||
<label>768x768</label>
|
||||
<width>768</width>
|
||||
<height>768</height>
|
||||
<category>Square</category>
|
||||
<sortOrder>20</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_1024x1024</defName>
|
||||
<label>1024x1024</label>
|
||||
<width>1024</width>
|
||||
<height>1024</height>
|
||||
<category>Square</category>
|
||||
<sortOrder>30</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<!-- Portrait Sizes -->
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_512x768</defName>
|
||||
<label>512x768</label>
|
||||
<width>512</width>
|
||||
<height>768</height>
|
||||
<category>Portrait</category>
|
||||
<sortOrder>40</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_768x1024</defName>
|
||||
<label>768x1024</label>
|
||||
<width>768</width>
|
||||
<height>1024</height>
|
||||
<category>Portrait</category>
|
||||
<sortOrder>50</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_896x1152</defName>
|
||||
<label>896x1152</label>
|
||||
<width>896</width>
|
||||
<height>1152</height>
|
||||
<category>Portrait</category>
|
||||
<sortOrder>60</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<!-- Landscape Sizes -->
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_768x512</defName>
|
||||
<label>768x512</label>
|
||||
<width>768</width>
|
||||
<height>512</height>
|
||||
<category>Landscape</category>
|
||||
<sortOrder>70</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_1024x768</defName>
|
||||
<label>1024x768</label>
|
||||
<width>1024</width>
|
||||
<height>768</height>
|
||||
<category>Landscape</category>
|
||||
<sortOrder>80</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_1152x896</defName>
|
||||
<label>1152x896</label>
|
||||
<width>1152</width>
|
||||
<height>1152</height>
|
||||
<category>Landscape</category>
|
||||
<sortOrder>90</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
</Defs>
|
||||
124
Defs/README.md
Normal file
124
Defs/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# AI Images - Defs Documentation
|
||||
|
||||
This folder contains XML definition files that allow you to easily customize art styles and image size presets without recompiling the mod.
|
||||
|
||||
## Art Style Definitions (ArtStyleDefs.xml)
|
||||
|
||||
Art styles define how images should be generated, including prompts, quality tags, and negative prompts.
|
||||
|
||||
### Structure
|
||||
|
||||
```xml
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_MyStyle</defName>
|
||||
<label>My Custom Style</label>
|
||||
<description>Description of the style</description>
|
||||
<positivePrompt>style keywords here</positivePrompt>
|
||||
<negativePrompt>things to avoid</negativePrompt>
|
||||
<qualityTags>additional quality tags</qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>100</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- **defName**: Unique identifier (must start with `ArtStyle_`)
|
||||
- **label**: Display name shown in the UI
|
||||
- **description**: Tooltip text explaining the style
|
||||
- **positivePrompt**: Keywords added to the positive prompt (e.g., "photorealistic, 8k uhd")
|
||||
- **negativePrompt**: Keywords added to the negative prompt (e.g., "cartoon, anime")
|
||||
- **qualityTags**: Style-specific quality tags
|
||||
- **addBaseQualityTags**: If true, adds "highly detailed, professional, masterpiece, best quality"
|
||||
- **addBaseNegativePrompts**: If true, adds base negative prompts like "ugly, deformed, low quality"
|
||||
- **sortOrder**: Determines order in the UI (lower numbers appear first)
|
||||
|
||||
### Example: Custom Watercolor Style
|
||||
|
||||
```xml
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_Watercolor</defName>
|
||||
<label>Watercolor</label>
|
||||
<description>Soft watercolor painting style</description>
|
||||
<positivePrompt>watercolor painting, soft colors, flowing paint, artistic</positivePrompt>
|
||||
<negativePrompt>photograph, digital art, sharp edges</negativePrompt>
|
||||
<qualityTags>traditional art, paper texture</qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>65</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
```
|
||||
|
||||
## Image Size Presets (ImageSizePresetDefs.xml)
|
||||
|
||||
Image size presets provide quick buttons for common image dimensions.
|
||||
|
||||
### Structure
|
||||
|
||||
```xml
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_1024x1024</defName>
|
||||
<label>1024x1024</label>
|
||||
<width>1024</width>
|
||||
<height>1024</height>
|
||||
<category>Square</category>
|
||||
<sortOrder>30</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- **defName**: Unique identifier (should start with `Size_`)
|
||||
- **label**: Display text on the button
|
||||
- **width**: Image width in pixels
|
||||
- **height**: Image height in pixels
|
||||
- **category**: Grouping category (Square, Portrait, Landscape, or custom)
|
||||
- **sortOrder**: Determines button order (lower numbers appear first)
|
||||
|
||||
### Example: Ultra-wide Size
|
||||
|
||||
```xml
|
||||
<AIImages.ImageSizePresetDef>
|
||||
<defName>Size_2048x1024</defName>
|
||||
<label>2048x1024</label>
|
||||
<width>2048</width>
|
||||
<height>1024</height>
|
||||
<category>Ultrawide</category>
|
||||
<sortOrder>95</sortOrder>
|
||||
</AIImages.ImageSizePresetDef>
|
||||
```
|
||||
|
||||
## Adding Custom Definitions
|
||||
|
||||
1. **Create a new XML file** in the `Defs` folder
|
||||
2. **Start with the XML header**:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Defs>
|
||||
<!-- Your definitions here -->
|
||||
</Defs>
|
||||
```
|
||||
3. **Add your definitions** using the structures above
|
||||
4. **Restart RimWorld** to load the new definitions
|
||||
|
||||
## Tips
|
||||
|
||||
- Keep `defName` unique to avoid conflicts
|
||||
- Use descriptive `label` values for the UI
|
||||
- Adjust `sortOrder` to organize items logically
|
||||
- Test your prompts with different characters to ensure good results
|
||||
- For art styles, experiment with different combinations of tags
|
||||
- Consider using existing styles as templates
|
||||
|
||||
## Compatibility
|
||||
|
||||
These definitions are compatible with other mods. If another mod adds art styles or size presets, they will all appear together in the UI.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Style doesn't appear**: Check that `defName` is unique and starts with `ArtStyle_`
|
||||
- **Size preset missing**: Verify the XML syntax and that `defName` starts with `Size_`
|
||||
- **Prompts not working**: Make sure prompts are in English and follow Stable Diffusion prompt syntax
|
||||
- **XML errors**: Use an XML validator to check your file for syntax errors
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
<AIImages.Window.Title>AI Image Generator</AIImages.Window.Title>
|
||||
<AIImages.Window.PawnLabel>Character: {0}</AIImages.Window.PawnLabel>
|
||||
<AIImages.Window.Refresh>Refresh</AIImages.Window.Refresh>
|
||||
<AIImages.Copy>Copy</AIImages.Copy>
|
||||
<!-- Character Info -->
|
||||
<AIImages.CharacterInfo.Title>Character Information</AIImages.CharacterInfo.Title>
|
||||
<AIImages.Info.Gender>Gender</AIImages.Info.Gender>
|
||||
<AIImages.Info.Age>Age</AIImages.Info.Age>
|
||||
<AIImages.Info.BodyType>Body Type</AIImages.Info.BodyType>
|
||||
<AIImages.Info.SkinTone>Skin Tone</AIImages.Info.SkinTone>
|
||||
<AIImages.Info.Hair>Hairstyle</AIImages.Info.Hair>
|
||||
<AIImages.Info.HairColor>Hair Color</AIImages.Info.HairColor>
|
||||
<AIImages.Info.Traits>Traits</AIImages.Info.Traits>
|
||||
<AIImages.Info.Apparel>Apparel</AIImages.Info.Apparel>
|
||||
<AIImages.Info.Genes>Genes</AIImages.Info.Genes>
|
||||
<AIImages.Info.Hediffs>Hediffs</AIImages.Info.Hediffs>
|
||||
<!-- Appearance -->
|
||||
<AIImages.Appearance.SectionTitle>Appearance</AIImages.Appearance.SectionTitle>
|
||||
<AIImages.Appearance.NoInfo>Appearance information unavailable</AIImages.Appearance.NoInfo>
|
||||
@@ -29,26 +42,42 @@
|
||||
<AIImages.Apparel.ColorDesc> Color: {0}</AIImages.Apparel.ColorDesc>
|
||||
<!-- Stable Diffusion Prompt -->
|
||||
<AIImages.Prompt.SectionTitle>Stable Diffusion Prompt</AIImages.Prompt.SectionTitle>
|
||||
<AIImages.Prompt.PositiveTitle>Positive Prompt</AIImages.Prompt.PositiveTitle>
|
||||
<AIImages.Prompt.NegativeTitle>Negative Prompt</AIImages.Prompt.NegativeTitle>
|
||||
<AIImages.Prompt.CopyButton>Copy Prompt</AIImages.Prompt.CopyButton>
|
||||
<AIImages.Prompt.CopyPositive>Copy Positive</AIImages.Prompt.CopyPositive>
|
||||
<AIImages.Prompt.CopyNegative>Copy Negative</AIImages.Prompt.CopyNegative>
|
||||
<AIImages.Prompt.Copied>Copied!</AIImages.Prompt.Copied>
|
||||
<!-- Generation -->
|
||||
<AIImages.Generation.Generate>Generate Image</AIImages.Generation.Generate>
|
||||
<AIImages.Generation.Generating>Generating...</AIImages.Generation.Generating>
|
||||
<AIImages.Generation.Cancel>Cancel Generation</AIImages.Generation.Cancel>
|
||||
<AIImages.Generation.InProgress>Generating image, please wait...</AIImages.Generation.InProgress>
|
||||
<AIImages.Generation.Success>Image generated successfully!</AIImages.Generation.Success>
|
||||
<AIImages.Generation.Failed>Generation failed</AIImages.Generation.Failed>
|
||||
<AIImages.Generation.Cancelled>Generation cancelled by user</AIImages.Generation.Cancelled>
|
||||
<AIImages.Generation.Cancelling>Cancelling generation...</AIImages.Generation.Cancelling>
|
||||
<AIImages.Generation.Error>Generation error</AIImages.Generation.Error>
|
||||
<AIImages.Generation.SavedTo>Image saved to: {0}</AIImages.Generation.SavedTo>
|
||||
<AIImages.Generation.LoadedFromSave>Loaded saved portrait</AIImages.Generation.LoadedFromSave>
|
||||
<AIImages.Generation.NoImage>No image generated yet.\nClick "Generate Image" to start.</AIImages.Generation.NoImage>
|
||||
<!-- Image Type -->
|
||||
<AIImages.ImageType.Label>Image Type</AIImages.ImageType.Label>
|
||||
<AIImages.ImageType.Portrait>Portrait</AIImages.ImageType.Portrait>
|
||||
<AIImages.ImageType.FullBody>Full Body</AIImages.ImageType.FullBody>
|
||||
<!-- Settings -->
|
||||
<AIImages.Settings.ApiSection>API Settings</AIImages.Settings.ApiSection>
|
||||
<AIImages.Settings.ApiSectionTooltip>Configure connection to Stable Diffusion API</AIImages.Settings.ApiSectionTooltip>
|
||||
<AIImages.Settings.ApiEndpoint>API Endpoint</AIImages.Settings.ApiEndpoint>
|
||||
<AIImages.Settings.TestConnection>Test Connection</AIImages.Settings.TestConnection>
|
||||
<AIImages.Settings.LoadModels>Load Available Models</AIImages.Settings.LoadModels>
|
||||
<AIImages.Settings.LoadFromApi>Load from API</AIImages.Settings.LoadFromApi>
|
||||
<AIImages.Settings.Model>Model</AIImages.Settings.Model>
|
||||
<AIImages.Settings.NoModelSelected>No model selected</AIImages.Settings.NoModelSelected>
|
||||
<AIImages.Settings.LoadModelsFirst>Load models first</AIImages.Settings.LoadModelsFirst>
|
||||
<AIImages.Settings.ConnectionSuccess>Successfully connected to API!</AIImages.Settings.ConnectionSuccess>
|
||||
<AIImages.Settings.ConnectionFailed>Failed to connect to API. Check endpoint and ensure Stable Diffusion WebUI is running.</AIImages.Settings.ConnectionFailed>
|
||||
<AIImages.Settings.ModelsLoaded>Loaded {0} models from API</AIImages.Settings.ModelsLoaded>
|
||||
<AIImages.Settings.NoModelsFound>No models found. Check API connection.</AIImages.Settings.NoModelsFound>
|
||||
<AIImages.Settings.AllLoaded>Loaded {0} models, {1} samplers and {2} schedulers from API</AIImages.Settings.AllLoaded>
|
||||
<AIImages.Settings.NothingLoaded>Nothing loaded. Check API connection.</AIImages.Settings.NothingLoaded>
|
||||
<AIImages.Settings.GenerationSection>Generation Settings</AIImages.Settings.GenerationSection>
|
||||
<AIImages.Settings.GenerationSectionTooltip>Configure image generation parameters</AIImages.Settings.GenerationSectionTooltip>
|
||||
<AIImages.Settings.ArtStyle>Art Style</AIImages.Settings.ArtStyle>
|
||||
@@ -58,6 +87,7 @@
|
||||
<AIImages.Settings.Width>Width</AIImages.Settings.Width>
|
||||
<AIImages.Settings.Height>Height</AIImages.Settings.Height>
|
||||
<AIImages.Settings.Sampler>Sampler</AIImages.Settings.Sampler>
|
||||
<AIImages.Settings.Scheduler>Schedule Type</AIImages.Settings.Scheduler>
|
||||
<AIImages.Settings.PromptsSection>Prompts</AIImages.Settings.PromptsSection>
|
||||
<AIImages.Settings.PromptsSectionTooltip>Base prompts that will be added to all generations</AIImages.Settings.PromptsSectionTooltip>
|
||||
<AIImages.Settings.BasePositivePrompt>Base Positive Prompt</AIImages.Settings.BasePositivePrompt>
|
||||
@@ -66,5 +96,29 @@
|
||||
<AIImages.Settings.AutoLoadModels>Auto-load models on startup</AIImages.Settings.AutoLoadModels>
|
||||
<AIImages.Settings.ShowTechnicalInfo>Show technical information</AIImages.Settings.ShowTechnicalInfo>
|
||||
<AIImages.Settings.SaveHistory>Save generation history</AIImages.Settings.SaveHistory>
|
||||
<AIImages.Settings.SaveImagesToServer>Save images to server</AIImages.Settings.SaveImagesToServer>
|
||||
<AIImages.Settings.SaveImagesToServerTooltip>When enabled, images will also be saved to the Stable Diffusion server's output folder (in addition to the local save folder)</AIImages.Settings.SaveImagesToServerTooltip>
|
||||
<AIImages.Settings.EnableDebugLogs>Enable debug logs</AIImages.Settings.EnableDebugLogs>
|
||||
<AIImages.Settings.EnableDebugLogsTooltip>When enabled, detailed debug information will be logged to help troubleshoot issues</AIImages.Settings.EnableDebugLogsTooltip>
|
||||
<AIImages.Settings.SavePath>Save Path</AIImages.Settings.SavePath>
|
||||
<AIImages.Settings.ClearAllImages>Clear All Generated Images</AIImages.Settings.ClearAllImages>
|
||||
<AIImages.Settings.ClearAllImagesConfirm>Are you sure you want to delete all generated portrait images? This action cannot be undone.</AIImages.Settings.ClearAllImagesConfirm>
|
||||
<AIImages.Settings.ClearAllImagesSuccess>Successfully deleted {0} portrait image(s)</AIImages.Settings.ClearAllImagesSuccess>
|
||||
<!-- Gallery -->
|
||||
<AIImages.Gallery.Title>AI Images Gallery</AIImages.Gallery.Title>
|
||||
<AIImages.Gallery.Count>Total images: {0}</AIImages.Gallery.Count>
|
||||
<AIImages.Gallery.Empty>Gallery is empty</AIImages.Gallery.Empty>
|
||||
<AIImages.Gallery.LoadError>Load error</AIImages.Gallery.LoadError>
|
||||
<AIImages.Gallery.DeleteSelected>Delete selected</AIImages.Gallery.DeleteSelected>
|
||||
<AIImages.Gallery.DeleteAll>Delete all ({0})</AIImages.Gallery.DeleteAll>
|
||||
<AIImages.Gallery.ConfirmDelete>Delete selected image?</AIImages.Gallery.ConfirmDelete>
|
||||
<AIImages.Gallery.ConfirmDeleteAll>Delete all {0} images? This action cannot be undone.</AIImages.Gallery.ConfirmDeleteAll>
|
||||
<AIImages.Gallery.DeleteError>Delete error: {0}</AIImages.Gallery.DeleteError>
|
||||
<AIImages.Gallery.Deleted>Image successfully deleted</AIImages.Gallery.Deleted>
|
||||
<AIImages.Gallery.AllDeleted>Successfully deleted {0} images</AIImages.Gallery.AllDeleted>
|
||||
<AIImages.Gallery.OpenGallery>Open gallery</AIImages.Gallery.OpenGallery>
|
||||
<AIImages.Gallery.ImagesCount>({0})</AIImages.Gallery.ImagesCount>
|
||||
<!-- Log -->
|
||||
<AIImages.Log.Entries>Log Events</AIImages.Log.Entries>
|
||||
<AIImages.Log.GenerateImage>Generate AI Image</AIImages.Log.GenerateImage>
|
||||
</LanguageData>
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
<AIImages.Window.Title>Генератор AI Изображений</AIImages.Window.Title>
|
||||
<AIImages.Window.PawnLabel>Персонаж: {0}</AIImages.Window.PawnLabel>
|
||||
<AIImages.Window.Refresh>Обновить</AIImages.Window.Refresh>
|
||||
<AIImages.Copy>Копировать</AIImages.Copy>
|
||||
<!-- Character Info -->
|
||||
<AIImages.CharacterInfo.Title>Информация о персонаже</AIImages.CharacterInfo.Title>
|
||||
<AIImages.Info.Gender>Пол</AIImages.Info.Gender>
|
||||
<AIImages.Info.Age>Возраст</AIImages.Info.Age>
|
||||
<AIImages.Info.BodyType>Тип тела</AIImages.Info.BodyType>
|
||||
<AIImages.Info.SkinTone>Тон кожи</AIImages.Info.SkinTone>
|
||||
<AIImages.Info.Hair>Прическа</AIImages.Info.Hair>
|
||||
<AIImages.Info.HairColor>Цвет волос</AIImages.Info.HairColor>
|
||||
<AIImages.Info.Traits>Черты характера</AIImages.Info.Traits>
|
||||
<AIImages.Info.Apparel>Одежда</AIImages.Info.Apparel>
|
||||
<AIImages.Info.Genes>Гены</AIImages.Info.Genes>
|
||||
<AIImages.Info.Hediffs>Состояния</AIImages.Info.Hediffs>
|
||||
<!-- Appearance -->
|
||||
<AIImages.Appearance.SectionTitle>Внешность</AIImages.Appearance.SectionTitle>
|
||||
<AIImages.Appearance.NoInfo>Информация о внешности недоступна</AIImages.Appearance.NoInfo>
|
||||
@@ -29,26 +42,42 @@
|
||||
<AIImages.Apparel.ColorDesc> Цвет: {0}</AIImages.Apparel.ColorDesc>
|
||||
<!-- Stable Diffusion Prompt -->
|
||||
<AIImages.Prompt.SectionTitle>Промпт Stable Diffusion</AIImages.Prompt.SectionTitle>
|
||||
<AIImages.Prompt.PositiveTitle>Позитивный промпт</AIImages.Prompt.PositiveTitle>
|
||||
<AIImages.Prompt.NegativeTitle>Негативный промпт</AIImages.Prompt.NegativeTitle>
|
||||
<AIImages.Prompt.CopyButton>Копировать промпт</AIImages.Prompt.CopyButton>
|
||||
<AIImages.Prompt.CopyPositive>Копировать позитивный</AIImages.Prompt.CopyPositive>
|
||||
<AIImages.Prompt.CopyNegative>Копировать негативный</AIImages.Prompt.CopyNegative>
|
||||
<AIImages.Prompt.Copied>Скопировано!</AIImages.Prompt.Copied>
|
||||
<!-- Generation -->
|
||||
<AIImages.Generation.Generate>Сгенерировать изображение</AIImages.Generation.Generate>
|
||||
<AIImages.Generation.Generating>Генерация...</AIImages.Generation.Generating>
|
||||
<AIImages.Generation.Cancel>Отменить генерацию</AIImages.Generation.Cancel>
|
||||
<AIImages.Generation.InProgress>Генерируется изображение, пожалуйста подождите...</AIImages.Generation.InProgress>
|
||||
<AIImages.Generation.Success>Изображение успешно сгенерировано!</AIImages.Generation.Success>
|
||||
<AIImages.Generation.Failed>Ошибка генерации</AIImages.Generation.Failed>
|
||||
<AIImages.Generation.Cancelled>Генерация отменена пользователем</AIImages.Generation.Cancelled>
|
||||
<AIImages.Generation.Cancelling>Отмена генерации...</AIImages.Generation.Cancelling>
|
||||
<AIImages.Generation.Error>Ошибка генерации</AIImages.Generation.Error>
|
||||
<AIImages.Generation.SavedTo>Изображение сохранено в: {0}</AIImages.Generation.SavedTo>
|
||||
<AIImages.Generation.LoadedFromSave>Загружен сохраненный портрет</AIImages.Generation.LoadedFromSave>
|
||||
<AIImages.Generation.NoImage>Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала.</AIImages.Generation.NoImage>
|
||||
<!-- Image Type -->
|
||||
<AIImages.ImageType.Label>Тип изображения</AIImages.ImageType.Label>
|
||||
<AIImages.ImageType.Portrait>Портрет</AIImages.ImageType.Portrait>
|
||||
<AIImages.ImageType.FullBody>Полное тело</AIImages.ImageType.FullBody>
|
||||
<!-- Settings -->
|
||||
<AIImages.Settings.ApiSection>Настройки API</AIImages.Settings.ApiSection>
|
||||
<AIImages.Settings.ApiSectionTooltip>Настройка подключения к API Stable Diffusion</AIImages.Settings.ApiSectionTooltip>
|
||||
<AIImages.Settings.ApiEndpoint>Адрес API</AIImages.Settings.ApiEndpoint>
|
||||
<AIImages.Settings.TestConnection>Проверить соединение</AIImages.Settings.TestConnection>
|
||||
<AIImages.Settings.LoadModels>Загрузить доступные модели</AIImages.Settings.LoadModels>
|
||||
<AIImages.Settings.LoadFromApi>Загрузить из API</AIImages.Settings.LoadFromApi>
|
||||
<AIImages.Settings.Model>Модель</AIImages.Settings.Model>
|
||||
<AIImages.Settings.NoModelSelected>Модель не выбрана</AIImages.Settings.NoModelSelected>
|
||||
<AIImages.Settings.LoadModelsFirst>Сначала загрузите модели</AIImages.Settings.LoadModelsFirst>
|
||||
<AIImages.Settings.ConnectionSuccess>Успешное подключение к API!</AIImages.Settings.ConnectionSuccess>
|
||||
<AIImages.Settings.ConnectionFailed>Не удалось подключиться к API. Проверьте адрес и убедитесь, что Stable Diffusion WebUI запущен.</AIImages.Settings.ConnectionFailed>
|
||||
<AIImages.Settings.ModelsLoaded>Загружено {0} моделей из API</AIImages.Settings.ModelsLoaded>
|
||||
<AIImages.Settings.NoModelsFound>Модели не найдены. Проверьте подключение к API.</AIImages.Settings.NoModelsFound>
|
||||
<AIImages.Settings.AllLoaded>Загружено {0} моделей, {1} сэмплеров и {2} планировщиков из API</AIImages.Settings.AllLoaded>
|
||||
<AIImages.Settings.NothingLoaded>Ничего не загружено. Проверьте подключение к API.</AIImages.Settings.NothingLoaded>
|
||||
<AIImages.Settings.GenerationSection>Настройки генерации</AIImages.Settings.GenerationSection>
|
||||
<AIImages.Settings.GenerationSectionTooltip>Настройка параметров генерации изображений</AIImages.Settings.GenerationSectionTooltip>
|
||||
<AIImages.Settings.ArtStyle>Художественный стиль</AIImages.Settings.ArtStyle>
|
||||
@@ -58,6 +87,7 @@
|
||||
<AIImages.Settings.Width>Ширина</AIImages.Settings.Width>
|
||||
<AIImages.Settings.Height>Высота</AIImages.Settings.Height>
|
||||
<AIImages.Settings.Sampler>Сэмплер</AIImages.Settings.Sampler>
|
||||
<AIImages.Settings.Scheduler>Тип планировщика</AIImages.Settings.Scheduler>
|
||||
<AIImages.Settings.PromptsSection>Промпты</AIImages.Settings.PromptsSection>
|
||||
<AIImages.Settings.PromptsSectionTooltip>Базовые промпты, которые будут добавлены ко всем генерациям</AIImages.Settings.PromptsSectionTooltip>
|
||||
<AIImages.Settings.BasePositivePrompt>Базовый позитивный промпт</AIImages.Settings.BasePositivePrompt>
|
||||
@@ -66,5 +96,29 @@
|
||||
<AIImages.Settings.AutoLoadModels>Автоматически загружать модели при запуске</AIImages.Settings.AutoLoadModels>
|
||||
<AIImages.Settings.ShowTechnicalInfo>Показывать техническую информацию</AIImages.Settings.ShowTechnicalInfo>
|
||||
<AIImages.Settings.SaveHistory>Сохранять историю генераций</AIImages.Settings.SaveHistory>
|
||||
<AIImages.Settings.SaveImagesToServer>Сохранять изображения на сервере</AIImages.Settings.SaveImagesToServer>
|
||||
<AIImages.Settings.SaveImagesToServerTooltip>Если включено, изображения также будут сохранены в папке вывода сервера Stable Diffusion (помимо локальной папки сохранения)</AIImages.Settings.SaveImagesToServerTooltip>
|
||||
<AIImages.Settings.EnableDebugLogs>Включить отладочные логи</AIImages.Settings.EnableDebugLogs>
|
||||
<AIImages.Settings.EnableDebugLogsTooltip>При включении будет выводиться подробная отладочная информация для помощи в решении проблем</AIImages.Settings.EnableDebugLogsTooltip>
|
||||
<AIImages.Settings.SavePath>Путь для сохранения</AIImages.Settings.SavePath>
|
||||
<AIImages.Settings.ClearAllImages>Очистить все сгенерированные изображения</AIImages.Settings.ClearAllImages>
|
||||
<AIImages.Settings.ClearAllImagesConfirm>Вы уверены, что хотите удалить все сгенерированные портреты? Это действие нельзя отменить.</AIImages.Settings.ClearAllImagesConfirm>
|
||||
<AIImages.Settings.ClearAllImagesSuccess>Успешно удалено {0} изображений портретов</AIImages.Settings.ClearAllImagesSuccess>
|
||||
<!-- Gallery -->
|
||||
<AIImages.Gallery.Title>Галерея AI изображений</AIImages.Gallery.Title>
|
||||
<AIImages.Gallery.Count>Всего изображений: {0}</AIImages.Gallery.Count>
|
||||
<AIImages.Gallery.Empty>В галерее пока нет изображений</AIImages.Gallery.Empty>
|
||||
<AIImages.Gallery.LoadError>Ошибка загрузки</AIImages.Gallery.LoadError>
|
||||
<AIImages.Gallery.DeleteSelected>Удалить выбранное</AIImages.Gallery.DeleteSelected>
|
||||
<AIImages.Gallery.DeleteAll>Удалить всё ({0})</AIImages.Gallery.DeleteAll>
|
||||
<AIImages.Gallery.ConfirmDelete>Удалить выбранное изображение?</AIImages.Gallery.ConfirmDelete>
|
||||
<AIImages.Gallery.ConfirmDeleteAll>Удалить все {0} изображений? Это действие нельзя отменить.</AIImages.Gallery.ConfirmDeleteAll>
|
||||
<AIImages.Gallery.DeleteError>Ошибка удаления: {0}</AIImages.Gallery.DeleteError>
|
||||
<AIImages.Gallery.Deleted>Изображение успешно удалено</AIImages.Gallery.Deleted>
|
||||
<AIImages.Gallery.AllDeleted>Успешно удалено {0} изображений</AIImages.Gallery.AllDeleted>
|
||||
<AIImages.Gallery.OpenGallery>Открыть галерею</AIImages.Gallery.OpenGallery>
|
||||
<AIImages.Gallery.ImagesCount>({0})</AIImages.Gallery.ImagesCount>
|
||||
<!-- Log -->
|
||||
<AIImages.Log.Entries>События из журнала</AIImages.Log.Entries>
|
||||
<AIImages.Log.GenerateImage>Сгенерировать AI изображение</AIImages.Log.GenerateImage>
|
||||
</LanguageData>
|
||||
|
||||
222
README.md
222
README.md
@@ -0,0 +1,222 @@
|
||||
# AI Images - RimWorld Mod
|
||||
|
||||
Мод для RimWorld, который генерирует AI-изображения персонажей (пешек) с помощью Stable Diffusion API.
|
||||
|
||||
## 🌟 Возможности
|
||||
|
||||
### 📸 Генерация AI-изображений
|
||||
- **Автоматическое описание персонажей** на основе:
|
||||
- Внешности (пол, возраст, тип тела, цвет кожи)
|
||||
- Прически и цвета волос
|
||||
- Одежды с материалами, качеством и цветами
|
||||
- Черт характера
|
||||
- Генов (биотехнологии)
|
||||
- Состояний здоровья
|
||||
|
||||
### 🎨 Гибкая настройка стилей
|
||||
- **9 предустановленных художественных стилей**:
|
||||
- Реалистичный (Photorealistic)
|
||||
- Полуреалистичный (Semi-Realistic)
|
||||
- Аниме
|
||||
- Концепт-арт
|
||||
- Цифровая живопись
|
||||
- Масляная живопись
|
||||
- Эскиз
|
||||
- Cel-shaded
|
||||
- Без стиля (кастомные промпты)
|
||||
|
||||
- **Возможность создания собственных стилей** через XML-конфигурацию
|
||||
- Базовые промпты и негативные промпты
|
||||
- Автоматическое добавление качественных тегов
|
||||
|
||||
### 📐 Размеры изображений
|
||||
- **Предустановки**: квадратные, портретные, ландшафтные
|
||||
- **Кастомные размеры**: полный контроль над шириной и высотой
|
||||
- **Два типа генерации**: портрет или полное тело
|
||||
|
||||
### 🖼️ Галерея изображений
|
||||
- **Отдельная галерея** для каждого персонажа
|
||||
- **Просмотр всех сгенерированных изображений** в удобной сетке
|
||||
- **Выбор изображений** кликом по миниатюре
|
||||
- **Удаление** отдельных или всех изображений
|
||||
- **Отображение даты создания** и имени файла
|
||||
|
||||
### ⚙️ Настройки Stable Diffusion
|
||||
- **Полная интеграция с Stable Diffusion WebUI**
|
||||
- Загрузка моделей, сэмплеров и планировщиков из API
|
||||
- Настройка количества шагов, CFG Scale, seed
|
||||
- Превью промптов с возможностью копирования
|
||||
- Прогресс-бар генерации с ETA
|
||||
|
||||
### 🎛️ Продвинутые функции
|
||||
- **Автоматическое сохранение** изображений
|
||||
- **История генераций** для каждого персонажа
|
||||
- **Обратная совместимость** со старыми сохранениями
|
||||
- **Двуязычный интерфейс** (русский/английский)
|
||||
- **Отладочные логи** для диагностики
|
||||
|
||||
## 🚀 Установка
|
||||
|
||||
### Требования
|
||||
- **RimWorld** версии 1.6
|
||||
- **Harmony** (автоматически подтягивается через зависимости)
|
||||
- **Stable Diffusion WebUI** (локально или удаленно)
|
||||
|
||||
### Зависимости
|
||||
Мод использует следующие зависимости (автоматически подтягиваются):
|
||||
- `brrainz.harmony` - Harmony
|
||||
- `rim.job.world` - RimJobWorld (необязательно, но в зависимостях)
|
||||
|
||||
### Шаги установки
|
||||
1. Клонируйте репозиторий или скачайте релиз
|
||||
2. Скопируйте папку `ai-images` в директорию `Mods` RimWorld
|
||||
3. Запустите Stable Diffusion WebUI
|
||||
4. В настройках RimWorld включите мод AI Images
|
||||
5. В настройках мода укажите адрес API Stable Diffusion (по умолчанию `http://127.0.0.1:7860`)
|
||||
|
||||
## 📖 Использование
|
||||
|
||||
### Генерация изображений
|
||||
|
||||
1. **Откройте окно AI Images**:
|
||||
- Нажмите на кнопку "AI Портрет" в панели действий персонажа
|
||||
|
||||
2. **Настройте параметры**:
|
||||
- Выберите художественный стиль
|
||||
- Выберите тип изображения (портрет/полное тело)
|
||||
- Настройте размер изображения
|
||||
- При необходимости измените другие параметры
|
||||
|
||||
3. **Сгенерируйте изображение**:
|
||||
- Нажмите кнопку "Сгенерировать изображение"
|
||||
- Дождитесь завершения генерации
|
||||
- Изображение автоматически сохранится
|
||||
|
||||
### Просмотр галереи
|
||||
|
||||
1. **Откройте галерею**:
|
||||
- В окне генерации нажмите кнопку "Открыть галерею"
|
||||
- Откроется окно со всеми изображениями персонажа
|
||||
|
||||
2. **Управление изображениями**:
|
||||
- Кликните по изображению для выбора
|
||||
- Нажмите "Удалить выбранное" для удаления конкретного изображения
|
||||
- Нажмите "Удалить всё" для очистки галереи
|
||||
|
||||
### Настройка стилей
|
||||
|
||||
Мод использует XML-конфигурацию для определения стилей. Вы можете создать свои собственные стили, редактируя файл `Defs/ArtStyleDefs.xml`.
|
||||
|
||||
Пример создания нового стиля:
|
||||
|
||||
```xml
|
||||
<AIImages.ArtStyleDef>
|
||||
<defName>ArtStyle_MyCustomStyle</defName>
|
||||
<label>Мой Кастомный Стиль</label>
|
||||
<description>Описание вашего стиля</description>
|
||||
<positivePrompt>ваши ключевые слова здесь</positivePrompt>
|
||||
<negativePrompt>чего избегать</negativePrompt>
|
||||
<qualityTags>дополнительные теги качества</qualityTags>
|
||||
<addBaseQualityTags>true</addBaseQualityTags>
|
||||
<addBaseNegativePrompts>true</addBaseNegativePrompts>
|
||||
<sortOrder>100</sortOrder>
|
||||
</AIImages.ArtStyleDef>
|
||||
```
|
||||
|
||||
Подробнее о конфигурации стилей в [Defs/README.md](Defs/README.md).
|
||||
|
||||
## 🎯 Особенности
|
||||
|
||||
### Умная генерация промптов
|
||||
Мод автоматически анализирует персонажа и создает детальные промпты для Stable Diffusion:
|
||||
- Описывает цвет кожи естественным языком
|
||||
- Добавляет информацию об одежде с материалами и качествами
|
||||
- Учитывает черты характера персонажа
|
||||
- Включает данные о генах из Biotech DLC
|
||||
- Адаптирует промпт в зависимости от типа изображения
|
||||
|
||||
### Оптимизация
|
||||
- Асинхронная генерация без блокировки игры
|
||||
- Возможность отмены генерации
|
||||
- Прогресс-бар с реальным временем выполнения
|
||||
- Эффективное управление памятью для текстур
|
||||
|
||||
### Обратная совместимость
|
||||
Старые сохранения с одним портретом автоматически мигрируют в новую систему галереи.
|
||||
|
||||
## 🛠️ Разработка
|
||||
|
||||
### Структура проекта
|
||||
```
|
||||
ai-images/
|
||||
├── About/ # Метаданные мода
|
||||
├── Assemblies/ # Скомпилированные DLL
|
||||
├── Defs/ # XML-конфигурации (стили, размеры)
|
||||
├── Languages/ # Переводы
|
||||
├── Source/ # Исходный код
|
||||
│ └── AIImages/
|
||||
│ ├── Components/ # Компоненты пешек
|
||||
│ ├── Defs/ # Классы определений
|
||||
│ ├── Helpers/ # Вспомогательные классы
|
||||
│ ├── Models/ # Модели данных
|
||||
│ ├── Patches/ # Harmony патчи
|
||||
│ ├── Services/ # Бизнес-логика
|
||||
│ ├── Settings/ # Настройки мода
|
||||
│ ├── UI/ # Пользовательский интерфейс
|
||||
│ └── Window_AIImage.cs # Главное окно
|
||||
└── Textures/ # Текстуры UI
|
||||
```
|
||||
|
||||
### Сборка
|
||||
```bash
|
||||
cd Source/AIImages
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
### Языки
|
||||
Мод поддерживает английский и русский языки. Переводы находятся в `Languages/`.
|
||||
|
||||
## 📝 История версий
|
||||
|
||||
### v1.0.0
|
||||
- Базовая генерация AI-изображений
|
||||
- Интеграция с Stable Diffusion API
|
||||
- 9 художественных стилей
|
||||
- Галерея изображений
|
||||
- Настройка размеров
|
||||
- Двуязычный интерфейс
|
||||
|
||||
## 🤝 Вклад
|
||||
|
||||
Приветствуются любые вклады! Пожалуйста:
|
||||
1. Форкните репозиторий
|
||||
2. Создайте ветку для вашей функции
|
||||
3. Закоммитьте изменения
|
||||
4. Отправьте Pull Request
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
См. файл [LICENSE](LICENSE) для деталей.
|
||||
|
||||
## 🙏 Благодарности
|
||||
|
||||
- Ludeon Studios за создание RimWorld
|
||||
- Automattic1111 за Stable Diffusion WebUI
|
||||
- Сообщество RimWorld за поддержку
|
||||
|
||||
## ⚠️ Известные ограничения
|
||||
|
||||
- Требует запущенный Stable Diffusion WebUI
|
||||
- Генерация изображений может занимать время в зависимости от настроек
|
||||
- Некоторые модели Stable Diffusion могут работать медленнее других
|
||||
|
||||
## 🐛 Сообщение об ошибках
|
||||
|
||||
Если вы нашли баг или хотите предложить улучшение, пожалуйста, создайте Issue на GitHub с подробным описанием.
|
||||
|
||||
---
|
||||
|
||||
**Автор**: mrleo1nid
|
||||
**Версия RimWorld**: 1.6
|
||||
**Версия мода**: 1.0.0
|
||||
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Krafs.Rimworld.Ref" Version="1.6.4566" />
|
||||
<PackageReference Include="Lib.Harmony" Version="2.4.1" />
|
||||
<PackageReference Include="StableDiffusionNet" Version="1.0.1" />
|
||||
<PackageReference Include="StableDiffusionNet.Core" Version="1.1.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,39 +1,91 @@
|
||||
using System;
|
||||
using AIImages.Services;
|
||||
using AIImages.Settings;
|
||||
using AIImages.Validation;
|
||||
using HarmonyLib;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages
|
||||
{
|
||||
/// <summary>
|
||||
/// Main mod class with settings support
|
||||
/// Main mod class with settings support and dependency injection
|
||||
/// </summary>
|
||||
public class AIImagesMod : Mod
|
||||
{
|
||||
public static AIImagesModSettings Settings { get; private set; }
|
||||
private static AIImagesMod _instance = null!;
|
||||
private readonly ServiceContainer _serviceContainer;
|
||||
|
||||
// Singleton сервисы
|
||||
public static IPawnDescriptionService PawnDescriptionService { get; private set; }
|
||||
public static IPromptGeneratorService PromptGeneratorService { get; private set; }
|
||||
public static IStableDiffusionApiService ApiService { get; private set; }
|
||||
/// <summary>
|
||||
/// Глобальный экземпляр мода (для доступа из других классов)
|
||||
/// </summary>
|
||||
public static AIImagesMod Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"[AI Images] Mod instance not initialized. This should not happen."
|
||||
);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
private set => _instance = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Настройки мода
|
||||
/// </summary>
|
||||
public static AIImagesModSettings Settings => Instance._serviceContainer.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Контейнер сервисов с dependency injection
|
||||
/// </summary>
|
||||
public static ServiceContainer Services => Instance._serviceContainer;
|
||||
|
||||
public AIImagesMod(ModContentPack content)
|
||||
: base(content)
|
||||
{
|
||||
Settings = GetSettings<AIImagesModSettings>();
|
||||
Instance = this;
|
||||
|
||||
// Инициализируем сервисы
|
||||
PawnDescriptionService = new PawnDescriptionService();
|
||||
PromptGeneratorService = new AdvancedPromptGenerator();
|
||||
ApiService = new StableDiffusionApiService(Settings.savePath);
|
||||
var settings = GetSettings<AIImagesModSettings>();
|
||||
|
||||
Log.Message("[AI Images] Mod initialized successfully with settings");
|
||||
// Валидируем настройки при загрузке
|
||||
var validationResult = SettingsValidator.Validate(settings);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
Log.Warning(
|
||||
$"[AI Images] Settings validation failed:\n{validationResult.GetErrorsAsString()}"
|
||||
);
|
||||
}
|
||||
|
||||
if (validationResult.HasWarnings)
|
||||
{
|
||||
Log.Warning(
|
||||
$"[AI Images] Settings validation warnings:\n{validationResult.GetWarningsAsString()}"
|
||||
);
|
||||
}
|
||||
|
||||
// Создаем контейнер сервисов
|
||||
try
|
||||
{
|
||||
_serviceContainer = new ServiceContainer(settings);
|
||||
Log.Message("[AI Images] Mod initialized successfully with dependency injection");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(
|
||||
$"[AI Images] Failed to initialize ServiceContainer: {ex.Message}\n{ex.StackTrace}"
|
||||
);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public override void DoSettingsWindowContents(Rect inRect)
|
||||
{
|
||||
AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings);
|
||||
AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings, _serviceContainer);
|
||||
base.DoSettingsWindowContents(inRect);
|
||||
}
|
||||
|
||||
@@ -41,6 +93,27 @@ namespace AIImages
|
||||
{
|
||||
return "AI Images";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Вызывается при выгрузке мода
|
||||
/// </summary>
|
||||
public override void WriteSettings()
|
||||
{
|
||||
base.WriteSettings();
|
||||
|
||||
// Валидируем настройки перед сохранением
|
||||
var validationResult = SettingsValidator.Validate(Settings);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
Log.Warning(
|
||||
$"[AI Images] Saving settings with validation errors:\n{validationResult.GetErrorsAsString()}"
|
||||
);
|
||||
Messages.Message(
|
||||
"AI Images: Some settings have validation errors. Check the log.",
|
||||
MessageTypeDefOf.CautionInput
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
117
Source/AIImages/Components/PawnPortraitComp.cs
Normal file
117
Source/AIImages/Components/PawnPortraitComp.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AIImages.Helpers;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Components
|
||||
{
|
||||
/// <summary>
|
||||
/// Компонент для хранения данных AI-сгенерированных портретов пешки
|
||||
/// </summary>
|
||||
public class PawnPortraitComp : ThingComp
|
||||
{
|
||||
/// <summary>
|
||||
/// Список путей к сохраненным портретам (галерея)
|
||||
/// </summary>
|
||||
private List<string> portraitPaths = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли сохраненные портреты
|
||||
/// </summary>
|
||||
public bool HasPortrait => portraitPaths != null && portraitPaths.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Количество портретов в галерее
|
||||
/// </summary>
|
||||
public int PortraitCount => portraitPaths?.Count ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Получить все пути к портретам
|
||||
/// </summary>
|
||||
public List<string> GetAllPortraits() => portraitPaths?.ToList() ?? new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Получить последний портрет (для обратной совместимости)
|
||||
/// </summary>
|
||||
public string PortraitPath => HasPortrait ? portraitPaths.Last() : null;
|
||||
|
||||
/// <summary>
|
||||
/// Добавить новый портрет в галерею
|
||||
/// </summary>
|
||||
public void AddPortrait(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return;
|
||||
|
||||
if (portraitPaths == null)
|
||||
portraitPaths = new List<string>();
|
||||
|
||||
portraitPaths.Add(path);
|
||||
DebugLogger.Log($"[AI Images] Added portrait to gallery: {path}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удалить портрет из галереи
|
||||
/// </summary>
|
||||
public bool RemovePortrait(string path)
|
||||
{
|
||||
if (portraitPaths == null || string.IsNullOrEmpty(path))
|
||||
return false;
|
||||
|
||||
bool removed = portraitPaths.Remove(path);
|
||||
if (removed)
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] Removed portrait from gallery: {path}");
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Очистить все портреты
|
||||
/// </summary>
|
||||
public void ClearPortraits()
|
||||
{
|
||||
if (portraitPaths != null)
|
||||
{
|
||||
int count = portraitPaths.Count;
|
||||
portraitPaths.Clear();
|
||||
DebugLogger.Log($"[AI Images] Cleared {count} portraits from gallery");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сохранение/загрузка данных
|
||||
/// </summary>
|
||||
public override void PostExposeData()
|
||||
{
|
||||
base.PostExposeData();
|
||||
|
||||
bool isSaving = Scribe.mode == LoadSaveMode.Saving;
|
||||
bool isLoading = Scribe.mode == LoadSaveMode.LoadingVars;
|
||||
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] PostExposeData for {parent?.LabelShort} - Mode: {Scribe.mode}, Portrait count: {PortraitCount}"
|
||||
);
|
||||
|
||||
// Сохраняем список портретов
|
||||
Scribe_Collections.Look(ref portraitPaths, "aiPortraitPaths", LookMode.Value);
|
||||
|
||||
// Обратная совместимость: если есть старый формат с одним портретом, добавляем его в список
|
||||
if (isLoading && (portraitPaths == null || portraitPaths.Count == 0))
|
||||
{
|
||||
string oldPortraitPath = null;
|
||||
Scribe_Values.Look(ref oldPortraitPath, "aiPortraitPath", null);
|
||||
if (!string.IsNullOrEmpty(oldPortraitPath))
|
||||
{
|
||||
portraitPaths = new List<string> { oldPortraitPath };
|
||||
DebugLogger.Log($"[AI Images] Migrated old single portrait to gallery: {oldPortraitPath}");
|
||||
}
|
||||
}
|
||||
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] PostExposeData completed for {parent?.LabelShort} - Portrait count: {PortraitCount}, HasPortrait: {HasPortrait}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Source/AIImages/Defs/ArtStyleDef.cs
Normal file
46
Source/AIImages/Defs/ArtStyleDef.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages
|
||||
{
|
||||
/// <summary>
|
||||
/// Определение художественного стиля для генерации изображений
|
||||
/// </summary>
|
||||
[SuppressMessage(
|
||||
"Major Code Smell",
|
||||
"S1104:Fields should not have public accessibility",
|
||||
Justification = "Required for RimWorld's Def system XML serialization"
|
||||
)]
|
||||
public class ArtStyleDef : Def
|
||||
{
|
||||
/// <summary>
|
||||
/// Промпт для позитивного описания стиля
|
||||
/// </summary>
|
||||
public string positivePrompt = "";
|
||||
|
||||
/// <summary>
|
||||
/// Промпт для негативного описания (что исключить)
|
||||
/// </summary>
|
||||
public string negativePrompt = "";
|
||||
|
||||
/// <summary>
|
||||
/// Теги качества специфичные для этого стиля
|
||||
/// </summary>
|
||||
public string qualityTags = "";
|
||||
|
||||
/// <summary>
|
||||
/// Добавлять ли базовые теги качества (highly detailed, professional, masterpiece, best quality)
|
||||
/// </summary>
|
||||
public bool addBaseQualityTags = true;
|
||||
|
||||
/// <summary>
|
||||
/// Добавлять ли базовые негативные промпты (ugly, deformed, low quality, etc.)
|
||||
/// </summary>
|
||||
public bool addBaseNegativePrompts = true;
|
||||
|
||||
/// <summary>
|
||||
/// Порядок сортировки в UI
|
||||
/// </summary>
|
||||
public int sortOrder = 100;
|
||||
}
|
||||
}
|
||||
36
Source/AIImages/Defs/ImageSizePresetDef.cs
Normal file
36
Source/AIImages/Defs/ImageSizePresetDef.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages
|
||||
{
|
||||
/// <summary>
|
||||
/// Предустановка размера изображения
|
||||
/// </summary>
|
||||
[SuppressMessage(
|
||||
"Major Code Smell",
|
||||
"S1104:Fields should not have public accessibility",
|
||||
Justification = "Required for RimWorld's Def system XML serialization"
|
||||
)]
|
||||
public class ImageSizePresetDef : Def
|
||||
{
|
||||
/// <summary>
|
||||
/// Ширина изображения в пикселях
|
||||
/// </summary>
|
||||
public int width = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Высота изображения в пикселях
|
||||
/// </summary>
|
||||
public int height = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Категория размера (Square, Portrait, Landscape)
|
||||
/// </summary>
|
||||
public string category = "Square";
|
||||
|
||||
/// <summary>
|
||||
/// Порядок сортировки в UI
|
||||
/// </summary>
|
||||
public int sortOrder = 100;
|
||||
}
|
||||
}
|
||||
145
Source/AIImages/Helpers/AsyncHelper.cs
Normal file
145
Source/AIImages/Helpers/AsyncHelper.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using RimWorld;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Вспомогательный класс для правильной обработки асинхронных операций в RimWorld
|
||||
/// Предотвращает fire-and-forget паттерн и обеспечивает централизованную обработку ошибок
|
||||
/// </summary>
|
||||
public static class AsyncHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Выполняет асинхронную задачу с обработкой ошибок
|
||||
/// </summary>
|
||||
public static async Task RunAsync(Func<Task> taskFunc, string operationName = "Operation")
|
||||
{
|
||||
try
|
||||
{
|
||||
await taskFunc();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.Message($"[AI Images] {operationName} was cancelled");
|
||||
Messages.Message($"{operationName} was cancelled", MessageTypeDefOf.RejectInput);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}");
|
||||
Messages.Message(
|
||||
$"Error in {operationName}: {ex.Message}",
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет асинхронную задачу с обработкой ошибок и callback при успехе
|
||||
/// </summary>
|
||||
public static async Task RunAsync<T>(
|
||||
Func<Task<T>> taskFunc,
|
||||
Action<T> onSuccess,
|
||||
string operationName = "Operation"
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
T result = await taskFunc();
|
||||
onSuccess?.Invoke(result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.Message($"[AI Images] {operationName} was cancelled");
|
||||
Messages.Message($"{operationName} was cancelled", MessageTypeDefOf.RejectInput);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}");
|
||||
Messages.Message(
|
||||
$"Error in {operationName}: {ex.Message}",
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет асинхронную задачу с полным контролем: onSuccess, onError, onCancel
|
||||
/// </summary>
|
||||
public static async Task RunAsync<T>(
|
||||
Func<Task<T>> taskFunc,
|
||||
Action<T> onSuccess,
|
||||
Action<Exception> onError = null,
|
||||
Action onCancel = null,
|
||||
string operationName = "Operation"
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
T result = await taskFunc();
|
||||
onSuccess?.Invoke(result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.Message($"[AI Images] {operationName} was cancelled");
|
||||
|
||||
if (onCancel != null)
|
||||
{
|
||||
onCancel();
|
||||
}
|
||||
else
|
||||
{
|
||||
Messages.Message(
|
||||
$"{operationName} was cancelled",
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}");
|
||||
|
||||
if (onError != null)
|
||||
{
|
||||
onError(ex);
|
||||
}
|
||||
else
|
||||
{
|
||||
Messages.Message(
|
||||
$"Error in {operationName}: {ex.Message}",
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Безопасно выполняет Task без ожидания результата, с логированием ошибок
|
||||
/// Используется когда нужен fire-and-forget, но с обработкой ошибок
|
||||
/// </summary>
|
||||
public static void FireAndForget(Task task, string operationName = "Background operation")
|
||||
{
|
||||
if (task == null)
|
||||
return;
|
||||
|
||||
task.ContinueWith(
|
||||
t =>
|
||||
{
|
||||
if (t.IsFaulted && t.Exception != null)
|
||||
{
|
||||
var ex = t.Exception.GetBaseException();
|
||||
Log.Error(
|
||||
$"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}"
|
||||
);
|
||||
}
|
||||
else if (t.IsCanceled)
|
||||
{
|
||||
Log.Message($"[AI Images] {operationName} was cancelled");
|
||||
}
|
||||
},
|
||||
TaskScheduler.Default
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
Source/AIImages/Helpers/DebugLogger.cs
Normal file
43
Source/AIImages/Helpers/DebugLogger.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Вспомогательный класс для условного отладочного логирования
|
||||
/// </summary>
|
||||
public static class DebugLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Логирует сообщение только если включены отладочные логи
|
||||
/// </summary>
|
||||
public static void Log(string message)
|
||||
{
|
||||
if (AIImagesMod.Settings?.enableDebugLogs == true)
|
||||
{
|
||||
Verse.Log.Message(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Логирует предупреждение только если включены отладочные логи
|
||||
/// </summary>
|
||||
public static void Warning(string message)
|
||||
{
|
||||
if (AIImagesMod.Settings?.enableDebugLogs == true)
|
||||
{
|
||||
Verse.Log.Warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Логирует ошибку только если включены отладочные логи
|
||||
/// </summary>
|
||||
public static void Error(string message)
|
||||
{
|
||||
if (AIImagesMod.Settings?.enableDebugLogs == true)
|
||||
{
|
||||
Verse.Log.Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
249
Source/AIImages/Helpers/PawnPortraitHelper.cs
Normal file
249
Source/AIImages/Helpers/PawnPortraitHelper.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AIImages.Components;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Вспомогательный класс для работы с портретами персонажей
|
||||
/// </summary>
|
||||
public static class PawnPortraitHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Получить компонент портрета пешки
|
||||
/// </summary>
|
||||
public static PawnPortraitComp GetPortraitComp(Pawn pawn)
|
||||
{
|
||||
if (pawn == null)
|
||||
{
|
||||
DebugLogger.Warning("[AI Images] GetPortraitComp called with null pawn");
|
||||
return null;
|
||||
}
|
||||
|
||||
var comp = pawn.TryGetComp<PawnPortraitComp>();
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] GetPortraitComp for {pawn.Name}: {(comp != null ? "Found" : "Not found")}"
|
||||
);
|
||||
return comp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сохранить путь к портрету на пешке (добавляет в галерею)
|
||||
/// </summary>
|
||||
public static void SavePortraitPath(Pawn pawn, string path)
|
||||
{
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] SavePortraitPath called for {pawn?.Name} with path: {path}"
|
||||
);
|
||||
|
||||
var comp = GetPortraitComp(pawn);
|
||||
if (comp != null)
|
||||
{
|
||||
comp.AddPortrait(path);
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] Successfully added portrait path for {pawn.Name}: {path}"
|
||||
);
|
||||
DebugLogger.Log($"[AI Images] Component now has {comp.PortraitCount} portraits");
|
||||
}
|
||||
else
|
||||
{
|
||||
DebugLogger.Error($"[AI Images] Failed to get portrait component for {pawn?.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получить путь к портрету пешки
|
||||
/// </summary>
|
||||
public static string GetPortraitPath(Pawn pawn)
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] GetPortraitPath called for {pawn?.Name}");
|
||||
|
||||
var comp = GetPortraitComp(pawn);
|
||||
if (comp == null)
|
||||
{
|
||||
DebugLogger.Warning($"[AI Images] No portrait component found for {pawn?.Name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] Portrait component found for {pawn?.Name}, path: '{comp.PortraitPath}'"
|
||||
);
|
||||
return comp.PortraitPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли у пешки сохраненный портрет
|
||||
/// </summary>
|
||||
public static bool HasPortrait(Pawn pawn)
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] HasPortrait called for {pawn?.Name}");
|
||||
|
||||
var comp = GetPortraitComp(pawn);
|
||||
if (comp == null)
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] No portrait component found for {pawn?.Name}");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasPortrait = comp.HasPortrait;
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] Portrait component for {pawn?.Name} has portrait: {hasPortrait} (path: '{comp.PortraitPath}')"
|
||||
);
|
||||
return hasPortrait;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Загрузить портрет пешки как текстуру
|
||||
/// </summary>
|
||||
public static Texture2D LoadPortrait(Pawn pawn)
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] LoadPortrait called for {pawn?.Name}");
|
||||
|
||||
string path = GetPortraitPath(pawn);
|
||||
DebugLogger.Log($"[AI Images] Retrieved path for {pawn?.Name}: '{path}'");
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] No portrait path found for {pawn?.Name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
DebugLogger.Warning(
|
||||
$"[AI Images] Portrait file does not exist for {pawn?.Name}: {path}"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
DebugLogger.Log($"[AI Images] Portrait file exists for {pawn?.Name}: {path}");
|
||||
|
||||
try
|
||||
{
|
||||
byte[] imageData = File.ReadAllBytes(path);
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] Successfully read {imageData.Length} bytes from {path}"
|
||||
);
|
||||
|
||||
Texture2D texture = new Texture2D(2, 2);
|
||||
texture.LoadImage(imageData);
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] Successfully loaded texture for {pawn?.Name}, size: {texture.width}x{texture.height}"
|
||||
);
|
||||
return texture;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
DebugLogger.Warning(
|
||||
$"[AI Images] Failed to load portrait for {pawn?.Name}: {ex.Message}"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Очистить портрет пешки (удаляет все портреты)
|
||||
/// </summary>
|
||||
public static void ClearPortrait(Pawn pawn)
|
||||
{
|
||||
var comp = GetPortraitComp(pawn);
|
||||
if (comp != null)
|
||||
{
|
||||
comp.ClearPortraits();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получить все пути к портретам пешки (галерея)
|
||||
/// </summary>
|
||||
public static List<string> GetAllPortraits(Pawn pawn)
|
||||
{
|
||||
var comp = GetPortraitComp(pawn);
|
||||
return comp?.GetAllPortraits() ?? new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получить количество портретов в галерее
|
||||
/// </summary>
|
||||
public static int GetPortraitCount(Pawn pawn)
|
||||
{
|
||||
var comp = GetPortraitComp(pawn);
|
||||
return comp?.PortraitCount ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удалить конкретный портрет из галереи
|
||||
/// </summary>
|
||||
public static bool RemovePortrait(Pawn pawn, string path)
|
||||
{
|
||||
var comp = GetPortraitComp(pawn);
|
||||
return comp?.RemovePortrait(path) ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Очистить все сгенерированные портреты
|
||||
/// </summary>
|
||||
public static int ClearAllPortraits(string savePath)
|
||||
{
|
||||
ClearPawnComponents();
|
||||
return DeletePortraitFiles(savePath);
|
||||
}
|
||||
|
||||
private static void ClearPawnComponents()
|
||||
{
|
||||
if (Current.Game?.Maps == null)
|
||||
return;
|
||||
|
||||
foreach (var map in Current.Game.Maps)
|
||||
{
|
||||
foreach (var pawn in map.mapPawns.AllPawns)
|
||||
{
|
||||
var comp = GetPortraitComp(pawn);
|
||||
if (comp != null && comp.HasPortrait)
|
||||
{
|
||||
comp.ClearPortraits();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int DeletePortraitFiles(string savePath)
|
||||
{
|
||||
int deletedCount = 0;
|
||||
string fullPath = Path.Combine(GenFilePaths.SaveDataFolderPath, savePath);
|
||||
|
||||
if (!Directory.Exists(fullPath))
|
||||
return deletedCount;
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(fullPath, "*.png");
|
||||
deletedCount = files.Count(TryDeleteFile);
|
||||
Log.Message($"[AI Images] Deleted {deletedCount} portrait files");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Error cleaning portraits directory: {ex.Message}");
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
private static bool TryDeleteFile(string file)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file);
|
||||
return true;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Warning($"[AI Images] Failed to delete file {file}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,10 @@ namespace AIImages.Models
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public string Sampler { get; set; }
|
||||
public string Scheduler { get; set; }
|
||||
public int Seed { get; set; }
|
||||
public string Model { get; set; }
|
||||
public bool SaveImagesToServer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,4 +49,40 @@ namespace AIImages.Models
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Прогресс генерации изображения
|
||||
/// </summary>
|
||||
public class GenerationProgress
|
||||
{
|
||||
/// <summary>
|
||||
/// Процент завершения (0.0 - 1.0)
|
||||
/// </summary>
|
||||
public double Progress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Текущий шаг
|
||||
/// </summary>
|
||||
public int CurrentStep { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Общее количество шагов
|
||||
/// </summary>
|
||||
public int TotalSteps { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Оставшееся время в секундах (приблизительно)
|
||||
/// </summary>
|
||||
public double EtaRelative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Идет ли генерация в данный момент
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Превью текущего состояния генерации (base64)
|
||||
/// </summary>
|
||||
public string CurrentImage { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,21 @@ namespace AIImages.Models
|
||||
public int Age { get; set; }
|
||||
public string BodyType { get; set; }
|
||||
public Color SkinColor { get; set; }
|
||||
public List<string> SkinColorGeneDefNames { get; set; }
|
||||
public List<string> HairColorGeneDefNames { get; set; }
|
||||
public string HairStyle { get; set; }
|
||||
public string HairDefName { get; set; }
|
||||
public Color HairColor { get; set; }
|
||||
public List<Trait> Traits { get; set; }
|
||||
public List<ApparelData> Apparel { get; set; }
|
||||
public string EventDescription { get; set; }
|
||||
|
||||
public PawnAppearanceData()
|
||||
{
|
||||
Traits = new List<Trait>();
|
||||
Apparel = new List<ApparelData>();
|
||||
SkinColorGeneDefNames = new List<string>();
|
||||
HairColorGeneDefNames = new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +39,9 @@ namespace AIImages.Models
|
||||
public class ApparelData
|
||||
{
|
||||
public string Label { get; set; }
|
||||
public string DefName { get; set; }
|
||||
public string Material { get; set; }
|
||||
public string MaterialDefName { get; set; }
|
||||
public QualityCategory? Quality { get; set; }
|
||||
public Color Color { get; set; }
|
||||
public string LayerType { get; set; }
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
namespace AIImages.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Тип генерации изображения
|
||||
/// </summary>
|
||||
public enum ImageType
|
||||
{
|
||||
Portrait, // Портрет
|
||||
FullBody, // Полное тело
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Настройки для генерации изображений через Stable Diffusion
|
||||
/// </summary>
|
||||
@@ -12,10 +21,11 @@ namespace AIImages.Models
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public string Sampler { get; set; }
|
||||
public string Scheduler { get; set; }
|
||||
public int Seed { get; set; }
|
||||
public string Model { get; set; }
|
||||
public ArtStyle ArtStyle { get; set; }
|
||||
public ShotType ShotType { get; set; }
|
||||
public string ArtStyleDefName { get; set; }
|
||||
public ImageType ImageType { get; set; }
|
||||
|
||||
public StableDiffusionSettings()
|
||||
{
|
||||
@@ -25,38 +35,12 @@ namespace AIImages.Models
|
||||
Width = 512;
|
||||
Height = 768;
|
||||
Sampler = "Euler a";
|
||||
Scheduler = "Automatic"; // С большой буквы для API
|
||||
Seed = -1; // Случайный seed
|
||||
ArtStyle = ArtStyle.Realistic;
|
||||
ShotType = ShotType.Portrait;
|
||||
ArtStyleDefName = "ArtStyle_Realistic";
|
||||
PositivePrompt = "";
|
||||
NegativePrompt = "ugly, deformed, low quality, blurry, bad anatomy, worst quality";
|
||||
ImageType = ImageType.Portrait;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Художественный стиль изображения
|
||||
/// </summary>
|
||||
public enum ArtStyle
|
||||
{
|
||||
Realistic,
|
||||
SemiRealistic,
|
||||
Anime,
|
||||
ConceptArt,
|
||||
DigitalPainting,
|
||||
OilPainting,
|
||||
Sketch,
|
||||
CellShaded,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Тип кадра/композиции
|
||||
/// </summary>
|
||||
public enum ShotType
|
||||
{
|
||||
Portrait, // Портрет (голова и плечи)
|
||||
HalfBody, // Половина тела
|
||||
FullBody, // Полное тело
|
||||
CloseUp, // Крупный план
|
||||
ThreeQuarter, // Три четверти
|
||||
}
|
||||
}
|
||||
|
||||
241
Source/AIImages/Patches/PawnLogPatch.cs
Normal file
241
Source/AIImages/Patches/PawnLogPatch.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Patches
|
||||
{
|
||||
/// <summary>
|
||||
/// Патч для получения записей лога пешки через ITab_Pawn_Log
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ITab_Pawn_Log), "FillTab")]
|
||||
public static class ITab_Pawn_Log_Patch
|
||||
{
|
||||
private static Pawn lastPawn = null;
|
||||
private static List<LogEntry> cachedEntries = new List<LogEntry>();
|
||||
|
||||
[HarmonyPrefix]
|
||||
public static void Prefix(ITab_Pawn_Log __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Получаем пешку через рефлексию
|
||||
var selPawnProp = AccessTools.Property(typeof(ITab), "SelPawn");
|
||||
if (selPawnProp != null)
|
||||
{
|
||||
var pawn = selPawnProp.GetValue(__instance) as Pawn;
|
||||
if (pawn != null)
|
||||
{
|
||||
// Пробуем получить записи напрямую через Pawn.logs
|
||||
var logsProperty = AccessTools.Property(typeof(Pawn), "logs");
|
||||
if (logsProperty != null)
|
||||
{
|
||||
var logs = logsProperty.GetValue(pawn);
|
||||
if (logs != null)
|
||||
{
|
||||
// Пробуем AllEntries
|
||||
var allEntriesProperty = AccessTools.Property(
|
||||
logs.GetType(),
|
||||
"AllEntries"
|
||||
);
|
||||
if (allEntriesProperty != null)
|
||||
{
|
||||
var entries = allEntriesProperty.GetValue(logs);
|
||||
if (entries is IEnumerable<LogEntry> entriesList)
|
||||
{
|
||||
cachedEntries = entriesList.ToList();
|
||||
lastPawn = pawn;
|
||||
UnityEngine.Debug.Log(
|
||||
$"[AI Images] Cached {cachedEntries.Count} log entries for {pawn.Name}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning(
|
||||
$"[AI Images] ITab_Pawn_Log patch error: {ex.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<LogEntry> GetCachedEntries(Pawn pawn)
|
||||
{
|
||||
if (pawn == lastPawn && cachedEntries != null && cachedEntries.Any())
|
||||
{
|
||||
return cachedEntries;
|
||||
}
|
||||
|
||||
// Если кэш не подходит, пытаемся получить напрямую
|
||||
if (pawn != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logsProperty = AccessTools.Property(typeof(Pawn), "logs");
|
||||
if (logsProperty != null)
|
||||
{
|
||||
var logs = logsProperty.GetValue(pawn);
|
||||
if (logs != null)
|
||||
{
|
||||
var allEntriesProperty = AccessTools.Property(
|
||||
logs.GetType(),
|
||||
"AllEntries"
|
||||
);
|
||||
if (allEntriesProperty != null)
|
||||
{
|
||||
var entries = allEntriesProperty.GetValue(logs);
|
||||
if (entries is IEnumerable<LogEntry> entriesList)
|
||||
{
|
||||
var list = entriesList.ToList();
|
||||
if (list.Any())
|
||||
{
|
||||
cachedEntries = list;
|
||||
lastPawn = pawn;
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning(
|
||||
$"[AI Images] Error getting entries directly: {ex.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new List<LogEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Патч для получения записей лога пешки
|
||||
/// Упрощённая версия - только для получения данных
|
||||
/// </summary>
|
||||
public static class PawnLogPatch
|
||||
{
|
||||
/// <summary>
|
||||
/// Получает все записи лога пешки
|
||||
/// </summary>
|
||||
public static IEnumerable<LogEntry> GetAllLogEntries(Pawn pawn)
|
||||
{
|
||||
// Сначала пробуем получить из кэша ITab_Pawn_Log
|
||||
var cachedEntries = ITab_Pawn_Log_Patch.GetCachedEntries(pawn);
|
||||
if (cachedEntries != null && cachedEntries.Any())
|
||||
{
|
||||
return cachedEntries;
|
||||
}
|
||||
|
||||
// Затем пробуем другие способы
|
||||
return GetAllLogEntriesInternal(pawn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Внутренний метод получения записей лога
|
||||
/// </summary>
|
||||
private static IEnumerable<LogEntry> GetAllLogEntriesInternal(Pawn pawn)
|
||||
{
|
||||
if (pawn == null)
|
||||
return Enumerable.Empty<LogEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
// Способ 1: Через property через reflection
|
||||
var logsProperty = AccessTools.Property(typeof(Pawn), "logs");
|
||||
if (logsProperty != null)
|
||||
{
|
||||
var logs = logsProperty.GetValue(pawn);
|
||||
if (logs != null)
|
||||
{
|
||||
var allEntriesProperty = AccessTools.Property(logs.GetType(), "AllEntries");
|
||||
if (allEntriesProperty != null)
|
||||
{
|
||||
var entries = allEntriesProperty.GetValue(logs);
|
||||
if (entries is IEnumerable<LogEntry> entriesList)
|
||||
{
|
||||
return entriesList;
|
||||
}
|
||||
}
|
||||
|
||||
// Пробуем метод GetEntries
|
||||
var getEntriesMethod = AccessTools.Method(logs.GetType(), "GetEntries");
|
||||
if (getEntriesMethod != null)
|
||||
{
|
||||
var entries = getEntriesMethod.Invoke(logs, null);
|
||||
if (entries is IEnumerable<LogEntry> entriesList)
|
||||
{
|
||||
return entriesList;
|
||||
}
|
||||
}
|
||||
|
||||
// Пробуем поле entries
|
||||
var entriesField = AccessTools.Field(logs.GetType(), "entries");
|
||||
if (entriesField != null)
|
||||
{
|
||||
var entries = entriesField.GetValue(logs);
|
||||
if (entries is IEnumerable<LogEntry> entriesList)
|
||||
{
|
||||
return entriesList;
|
||||
}
|
||||
if (entries is System.Collections.IList entriesCollection)
|
||||
{
|
||||
return entriesCollection.Cast<LogEntry>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Способ 3: Через story.logs
|
||||
if (pawn.story != null)
|
||||
{
|
||||
var logsField = AccessTools.Field(pawn.story.GetType(), "logs");
|
||||
if (logsField != null)
|
||||
{
|
||||
var logs = logsField.GetValue(pawn.story);
|
||||
if (logs != null)
|
||||
{
|
||||
var allEntriesProperty = AccessTools.Property(
|
||||
logs.GetType(),
|
||||
"AllEntries"
|
||||
);
|
||||
if (allEntriesProperty != null)
|
||||
{
|
||||
var entries = allEntriesProperty.GetValue(logs);
|
||||
if (entries is IEnumerable<LogEntry> entriesList)
|
||||
{
|
||||
return entriesList;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Логируем ошибку для отладки
|
||||
UnityEngine.Debug.LogWarning(
|
||||
$"[AI Images] Error getting log entries for {pawn?.Name}: {ex.Message}"
|
||||
);
|
||||
}
|
||||
|
||||
// Если все способы не сработали, логируем информацию
|
||||
UnityEngine.Debug.Log(
|
||||
$"[AI Images] Could not get log entries for {pawn?.Name}. "
|
||||
+ $"logs property exists: {AccessTools.Property(typeof(Pawn), "logs") != null}, "
|
||||
+ $"story property exists: {AccessTools.Property(typeof(Pawn), "story") != null}"
|
||||
);
|
||||
|
||||
return Enumerable.Empty<LogEntry>();
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Source/AIImages/Patches/PawnPortraitCompPatch.cs
Normal file
65
Source/AIImages/Patches/PawnPortraitCompPatch.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using AIImages.Components;
|
||||
using AIImages.Helpers;
|
||||
using HarmonyLib;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Patches
|
||||
{
|
||||
/// <summary>
|
||||
/// Патч для добавления PawnPortraitComp ко всем пешкам
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(ThingWithComps), nameof(ThingWithComps.InitializeComps))]
|
||||
public static class PawnPortraitCompPatch
|
||||
{
|
||||
private static FieldInfo allCompsField = AccessTools.Field(typeof(ThingWithComps), "comps");
|
||||
|
||||
[HarmonyPostfix]
|
||||
public static void AddPortraitComp(ThingWithComps __instance)
|
||||
{
|
||||
// Проверяем, является ли объект пешкой-гуманоидом и нет ли уже компонента
|
||||
if (
|
||||
__instance is Pawn pawn
|
||||
&& pawn.RaceProps?.Humanlike == true
|
||||
&& pawn.GetComp<PawnPortraitComp>() == null
|
||||
)
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] Adding portrait component to {pawn.Name}");
|
||||
|
||||
// Создаем компонент
|
||||
var comp = new PawnPortraitComp { parent = pawn };
|
||||
|
||||
// Инициализируем компонент
|
||||
comp.Initialize(null);
|
||||
|
||||
// Получаем список компонентов через рефлексию и добавляем наш
|
||||
var compsList = allCompsField.GetValue(pawn) as List<ThingComp>;
|
||||
if (compsList != null)
|
||||
{
|
||||
compsList.Add(comp);
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] Successfully added portrait component to {pawn.Name}"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
DebugLogger.Error($"[AI Images] Failed to get comps list for {pawn.Name}");
|
||||
}
|
||||
}
|
||||
else if (__instance is Pawn pawn2)
|
||||
{
|
||||
if (pawn2.RaceProps?.Humanlike != true)
|
||||
{
|
||||
DebugLogger.Log($"[AI Images] Skipping non-humanlike pawn: {pawn2.Name}");
|
||||
}
|
||||
else if (pawn2.GetComp<PawnPortraitComp>() != null)
|
||||
{
|
||||
DebugLogger.Log(
|
||||
$"[AI Images] Portrait component already exists for {pawn2.Name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ using Verse;
|
||||
namespace AIImages
|
||||
{
|
||||
/// <summary>
|
||||
/// Harmony patch to add a gizmo (button) to all colonist pawns
|
||||
/// Harmony patch to add a gizmo (button) to all pawns
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(Pawn), "GetGizmos")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
@@ -34,12 +34,8 @@ namespace AIImages
|
||||
yield return gizmo;
|
||||
}
|
||||
|
||||
// Only add button to colonist pawns that are spawned
|
||||
if (
|
||||
__instance.IsColonist
|
||||
&& __instance.Spawned
|
||||
&& __instance.Faction == Faction.OfPlayer
|
||||
)
|
||||
// Add button to all spawned pawns (not just colonists)
|
||||
if (__instance.Spawned)
|
||||
{
|
||||
yield return new Command_Action
|
||||
{
|
||||
|
||||
@@ -34,36 +34,6 @@ namespace AIImages.Services
|
||||
{ "Pretty", "attractive features, pleasant appearance, charming" },
|
||||
};
|
||||
|
||||
private static readonly Dictionary<ArtStyle, string> ArtStylePrompts = new Dictionary<
|
||||
ArtStyle,
|
||||
string
|
||||
>
|
||||
{
|
||||
{ ArtStyle.Realistic, "photorealistic, hyperrealistic, realistic photo, photography" },
|
||||
{ ArtStyle.SemiRealistic, "semi-realistic, detailed illustration, realistic art" },
|
||||
{ ArtStyle.Anime, "anime style, manga style, anime character" },
|
||||
{
|
||||
ArtStyle.ConceptArt,
|
||||
"concept art, digital art, artstation, professional concept design"
|
||||
},
|
||||
{ ArtStyle.DigitalPainting, "digital painting, painterly, brush strokes, artistic" },
|
||||
{ ArtStyle.OilPainting, "oil painting, traditional painting, canvas, fine art" },
|
||||
{ ArtStyle.Sketch, "pencil sketch, hand drawn, sketch art, line art" },
|
||||
{ ArtStyle.CellShaded, "cell shaded, flat colors, toon shading, stylized" },
|
||||
};
|
||||
|
||||
private static readonly Dictionary<ShotType, string> ShotTypePrompts = new Dictionary<
|
||||
ShotType,
|
||||
string
|
||||
>
|
||||
{
|
||||
{ ShotType.Portrait, "portrait, head and shoulders" },
|
||||
{ ShotType.HalfBody, "half body shot, waist up" },
|
||||
{ ShotType.FullBody, "full body, full length" },
|
||||
{ ShotType.CloseUp, "close up, face focus, detailed face" },
|
||||
{ ShotType.ThreeQuarter, "three-quarter view, 3/4 view" },
|
||||
};
|
||||
|
||||
public string GeneratePositivePrompt(
|
||||
PawnAppearanceData appearanceData,
|
||||
StableDiffusionSettings settings
|
||||
@@ -74,25 +44,36 @@ namespace AIImages.Services
|
||||
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
|
||||
// 1. Художественный стиль
|
||||
if (ArtStylePrompts.TryGetValue(settings.ArtStyle, out string stylePrompt))
|
||||
// 1. Базовый пользовательский промпт (если указан) - идет первым
|
||||
if (!string.IsNullOrEmpty(settings.PositivePrompt))
|
||||
{
|
||||
prompt.Append(stylePrompt);
|
||||
prompt.Append(settings.PositivePrompt.TrimEnd(',', ' '));
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 2. Тип кадра
|
||||
if (ShotTypePrompts.TryGetValue(settings.ShotType, out string shotPrompt))
|
||||
// 2. Художественный стиль
|
||||
var styleDef = DefDatabase<ArtStyleDef>.GetNamedSilentFail(settings.ArtStyleDefName);
|
||||
if (styleDef != null && !string.IsNullOrEmpty(styleDef.positivePrompt))
|
||||
{
|
||||
prompt.Append(shotPrompt);
|
||||
prompt.Append(" of ");
|
||||
prompt.Append(styleDef.positivePrompt);
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 3. Базовое описание (возраст и пол)
|
||||
prompt.Append(GetAgeAndGenderDescription(appearanceData));
|
||||
// 3. Тип кадра (portrait или full body)
|
||||
string frameType =
|
||||
settings.ImageType == Models.ImageType.FullBody ? "full body, " : "portrait, ";
|
||||
prompt.Append(frameType);
|
||||
|
||||
// 4. Пол персонажа (в формате anime/SD теги)
|
||||
string genderTag = appearanceData.Gender == Gender.Female ? "1girl" : "1boy";
|
||||
prompt.Append(genderTag);
|
||||
prompt.Append(", ");
|
||||
|
||||
// 4. Тип тела
|
||||
// 5. Точный возраст
|
||||
prompt.Append($"{appearanceData.Age} y.o.");
|
||||
prompt.Append(", ");
|
||||
|
||||
// 6. Тип тела
|
||||
string bodyType = GetBodyTypeDescription(appearanceData.BodyType);
|
||||
if (!string.IsNullOrEmpty(bodyType))
|
||||
{
|
||||
@@ -100,14 +81,15 @@ namespace AIImages.Services
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 5. Цвет кожи
|
||||
string skinTone = ColorDescriptionService.GetSkinToneDescription(
|
||||
appearanceData.SkinColor
|
||||
);
|
||||
// 7. Цвет кожи (сначала проверяем гены, затем используем цвет как fallback)
|
||||
string skinTone = GetSkinToneDescription(appearanceData);
|
||||
if (!string.IsNullOrEmpty(skinTone))
|
||||
{
|
||||
prompt.Append(skinTone);
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 6. Волосы
|
||||
// 8. Волосы
|
||||
string hairDescription = GetHairDescription(appearanceData);
|
||||
if (!string.IsNullOrEmpty(hairDescription))
|
||||
{
|
||||
@@ -115,7 +97,7 @@ namespace AIImages.Services
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 7. Настроение и выражение на основе черт характера
|
||||
// 9. Настроение и выражение на основе черт характера
|
||||
string moodDescription = GetMoodFromTraits(appearanceData.Traits);
|
||||
if (!string.IsNullOrEmpty(moodDescription))
|
||||
{
|
||||
@@ -123,7 +105,7 @@ namespace AIImages.Services
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 8. Одежда
|
||||
// 10. Одежда
|
||||
string apparelDescription = GetApparelDescription(appearanceData.Apparel);
|
||||
if (!string.IsNullOrEmpty(apparelDescription))
|
||||
{
|
||||
@@ -131,51 +113,78 @@ namespace AIImages.Services
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 9. Базовый пользовательский промпт (если указан)
|
||||
if (!string.IsNullOrEmpty(settings.PositivePrompt))
|
||||
{
|
||||
prompt.Append(settings.PositivePrompt);
|
||||
prompt.Append(", ");
|
||||
}
|
||||
|
||||
// 10. Качественные теги
|
||||
prompt.Append(GetQualityTags(settings.ArtStyle));
|
||||
// 11. Качественные теги
|
||||
prompt.Append(GetQualityTags(settings.ArtStyleDefName));
|
||||
|
||||
return prompt.ToString().Trim().TrimEnd(',');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Генерирует позитивный промпт на основе данных о персонаже и события
|
||||
/// </summary>
|
||||
public string GeneratePositivePromptWithEvent(
|
||||
PawnAppearanceData appearanceData,
|
||||
StableDiffusionSettings settings,
|
||||
string eventDescription
|
||||
)
|
||||
{
|
||||
if (appearanceData == null)
|
||||
return "portrait of a person";
|
||||
|
||||
// Генерируем базовый промпт
|
||||
string basePrompt = GeneratePositivePrompt(appearanceData, settings);
|
||||
|
||||
// Добавляем описание события, если оно есть
|
||||
if (!string.IsNullOrEmpty(eventDescription))
|
||||
{
|
||||
StringBuilder prompt = new StringBuilder(basePrompt);
|
||||
prompt.Append(", ");
|
||||
prompt.Append(eventDescription.ToLower());
|
||||
return prompt.ToString();
|
||||
}
|
||||
|
||||
return basePrompt;
|
||||
}
|
||||
|
||||
public string GenerateNegativePrompt(StableDiffusionSettings settings)
|
||||
{
|
||||
StringBuilder negativePrompt = new StringBuilder();
|
||||
|
||||
// Базовые негативные промпты
|
||||
// 1. Пользовательский негативный промпт (если указан) - идет первым
|
||||
if (!string.IsNullOrEmpty(settings.NegativePrompt))
|
||||
{
|
||||
negativePrompt.Append(settings.NegativePrompt.TrimEnd(',', ' '));
|
||||
}
|
||||
|
||||
// Получаем стиль из Def
|
||||
var styleDef = DefDatabase<ArtStyleDef>.GetNamedSilentFail(settings.ArtStyleDefName);
|
||||
if (styleDef == null || !styleDef.addBaseNegativePrompts)
|
||||
{
|
||||
// Для стилей без базовых негативов - используем только пользовательский промпт
|
||||
return negativePrompt.ToString().Trim();
|
||||
}
|
||||
|
||||
// 2. Базовые негативные промпты
|
||||
if (negativePrompt.Length > 0)
|
||||
{
|
||||
negativePrompt.Append(", ");
|
||||
}
|
||||
|
||||
negativePrompt.Append(
|
||||
"ugly, deformed, low quality, blurry, bad anatomy, worst quality, "
|
||||
);
|
||||
negativePrompt.Append(
|
||||
"mutated, disfigured, bad proportions, extra limbs, missing limbs, "
|
||||
"mutated, disfigured, bad proportions, extra limbs, missing limbs"
|
||||
);
|
||||
|
||||
// Специфичные для стиля негативы
|
||||
if (
|
||||
settings.ArtStyle == ArtStyle.Realistic
|
||||
|| settings.ArtStyle == ArtStyle.SemiRealistic
|
||||
)
|
||||
// 3. Специфичные для стиля негативы из Def
|
||||
if (!string.IsNullOrEmpty(styleDef.negativePrompt))
|
||||
{
|
||||
negativePrompt.Append("cartoon, anime, painting, drawing, illustration, ");
|
||||
}
|
||||
else if (settings.ArtStyle == ArtStyle.Anime)
|
||||
{
|
||||
negativePrompt.Append("realistic, photo, photography, 3d, ");
|
||||
negativePrompt.Append(", ");
|
||||
negativePrompt.Append(styleDef.negativePrompt);
|
||||
}
|
||||
|
||||
// Пользовательский негативный промпт
|
||||
if (!string.IsNullOrEmpty(settings.NegativePrompt))
|
||||
{
|
||||
negativePrompt.Append(settings.NegativePrompt);
|
||||
}
|
||||
|
||||
return negativePrompt.ToString().Trim().TrimEnd(',');
|
||||
return negativePrompt.ToString().Trim();
|
||||
}
|
||||
|
||||
public string GetFullPromptDescription(
|
||||
@@ -205,27 +214,6 @@ namespace AIImages.Services
|
||||
return description.ToString();
|
||||
}
|
||||
|
||||
private string GetAgeAndGenderDescription(PawnAppearanceData data)
|
||||
{
|
||||
string ageGroup = data.Age switch
|
||||
{
|
||||
< 18 => "young",
|
||||
< 25 => "young adult",
|
||||
< 35 => "adult",
|
||||
< 50 => "middle-aged",
|
||||
< 65 => "mature",
|
||||
_ => "elderly",
|
||||
};
|
||||
|
||||
string genderLabel = data.Gender switch
|
||||
{
|
||||
Gender.Male => "man",
|
||||
Gender.Female => "woman",
|
||||
_ => "person",
|
||||
};
|
||||
return $"{ageGroup} {genderLabel}";
|
||||
}
|
||||
|
||||
private string GetBodyTypeDescription(string bodyType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(bodyType))
|
||||
@@ -242,22 +230,98 @@ namespace AIImages.Services
|
||||
};
|
||||
}
|
||||
|
||||
private string GetSkinToneDescription(PawnAppearanceData data)
|
||||
{
|
||||
// Сначала проверяем специальные гены цвета кожи (зелёная, синяя и т.д.)
|
||||
if (data.SkinColorGeneDefNames != null && data.SkinColorGeneDefNames.Any())
|
||||
{
|
||||
// Ищем специальные гены (зелёная, синяя, красная и т.д.)
|
||||
foreach (var geneDefName in data.SkinColorGeneDefNames)
|
||||
{
|
||||
string specialSkinTone = GetSpecialGeneSkinTone(geneDefName);
|
||||
if (!string.IsNullOrEmpty(specialSkinTone))
|
||||
return specialSkinTone;
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет специальных генов, используем вычисленный RimWorld цвет
|
||||
return ColorDescriptionService.GetSkinToneDescription(data.SkinColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает описание для специальных генов цвета кожи
|
||||
/// </summary>
|
||||
private string GetSpecialGeneSkinTone(string geneDefName)
|
||||
{
|
||||
switch (geneDefName)
|
||||
{
|
||||
case "Skin_InkBlack":
|
||||
return "ink black skin";
|
||||
case "Skin_SlateGray":
|
||||
return "slate gray skin";
|
||||
case "Skin_LightGray":
|
||||
return "light gray skin";
|
||||
case "Skin_SheerWhite":
|
||||
return "sheer white skin";
|
||||
case "Skin_Blue":
|
||||
return "blue skin";
|
||||
case "Skin_Purple":
|
||||
return "purple skin";
|
||||
case "Skin_PaleRed":
|
||||
return "pale red skin";
|
||||
case "Skin_DeepRed":
|
||||
return "deep red skin";
|
||||
case "Skin_PaleYellow":
|
||||
return "pale yellow skin";
|
||||
case "Skin_DeepYellow":
|
||||
return "deep yellow skin";
|
||||
case "Skin_Orange":
|
||||
return "orange skin";
|
||||
case "Skin_Green":
|
||||
return "green skin";
|
||||
default:
|
||||
// Пытаемся определить по названию (defName всегда на английском)
|
||||
if (geneDefName.Contains("Green"))
|
||||
return "green skin";
|
||||
if (geneDefName.Contains("Blue"))
|
||||
return "blue skin";
|
||||
if (geneDefName.Contains("Red"))
|
||||
return "red skin";
|
||||
if (geneDefName.Contains("Yellow"))
|
||||
return "yellow skin";
|
||||
if (geneDefName.Contains("Purple"))
|
||||
return "purple skin";
|
||||
if (geneDefName.Contains("Orange"))
|
||||
return "orange skin";
|
||||
if (geneDefName.Contains("Black"))
|
||||
return "black skin";
|
||||
if (geneDefName.Contains("White"))
|
||||
return "white skin";
|
||||
if (geneDefName.Contains("Gray") || geneDefName.Contains("Grey"))
|
||||
return "gray skin";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetHairDescription(PawnAppearanceData data)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data.HairStyle))
|
||||
if (string.IsNullOrEmpty(data.HairDefName))
|
||||
return "";
|
||||
|
||||
StringBuilder hair = new StringBuilder();
|
||||
|
||||
// Цвет волос
|
||||
string hairColor = ColorDescriptionService.GetHairColorDescription(data.HairColor);
|
||||
// Цвет волос - сначала проверяем гены
|
||||
string hairColor = GetHairColorFromData(data);
|
||||
if (string.IsNullOrEmpty(hairColor))
|
||||
{
|
||||
// Если нет генов, используем цвет из RGB
|
||||
hairColor = ColorDescriptionService.GetHairColorDescription(data.HairColor);
|
||||
}
|
||||
hair.Append(hairColor);
|
||||
hair.Append(" ");
|
||||
hair.Append(" hair, ");
|
||||
|
||||
// Стиль прически (упрощаем сложные названия)
|
||||
string style = data
|
||||
.HairStyle.ToLower()
|
||||
.Replace("_", " ")
|
||||
// Стиль прически - используем DefName для английского названия
|
||||
string style = CleanDefName(data.HairDefName)
|
||||
.Replace("shaved", "very short")
|
||||
.Replace("mohawk", "mohawk hairstyle");
|
||||
|
||||
@@ -267,6 +331,94 @@ namespace AIImages.Services
|
||||
return hair.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает описание цвета волос из генов в данных персонажа
|
||||
/// </summary>
|
||||
private string GetHairColorFromData(PawnAppearanceData data)
|
||||
{
|
||||
if (data?.HairColorGeneDefNames == null || !data.HairColorGeneDefNames.Any())
|
||||
return null;
|
||||
|
||||
// Берем первый ген (они уже отсортированы по приоритету)
|
||||
string geneDefName = data.HairColorGeneDefNames.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(geneDefName))
|
||||
return null;
|
||||
|
||||
// Определяем цвет волос по названию гена
|
||||
return GetHairColorFromGeneDefName(geneDefName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает описание цвета волос из названия гена
|
||||
/// </summary>
|
||||
private string GetHairColorFromGeneDefName(string geneDefName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(geneDefName))
|
||||
return null;
|
||||
|
||||
// Специфичные описания для известных генов цвета волос
|
||||
switch (geneDefName)
|
||||
{
|
||||
case "Hair_Blond":
|
||||
case "Hair_Blonde":
|
||||
return "blond";
|
||||
case "Hair_Brown":
|
||||
return "brown";
|
||||
case "Hair_Black":
|
||||
return "black";
|
||||
case "Hair_White":
|
||||
return "white";
|
||||
case "Hair_Red":
|
||||
return "red";
|
||||
case "Hair_Ginger":
|
||||
return "ginger";
|
||||
case "Hair_Auburn":
|
||||
return "auburn";
|
||||
case "Hair_Copper":
|
||||
case "Hair_CopperBrown":
|
||||
return "copper-brown";
|
||||
case "Hair_Light":
|
||||
return "light";
|
||||
case "Hair_Dark":
|
||||
return "dark";
|
||||
default:
|
||||
// Пытаемся определить по названию (defName всегда на английском)
|
||||
if (geneDefName.Contains("Blond"))
|
||||
return "blond";
|
||||
if (geneDefName.Contains("Brown"))
|
||||
return "brown";
|
||||
if (geneDefName.Contains("Black"))
|
||||
return "black";
|
||||
if (geneDefName.Contains("White"))
|
||||
return "white";
|
||||
if (geneDefName.Contains("Red"))
|
||||
return "red";
|
||||
if (geneDefName.Contains("Ginger"))
|
||||
return "ginger";
|
||||
if (geneDefName.Contains("Auburn"))
|
||||
return "auburn";
|
||||
if (geneDefName.Contains("Copper"))
|
||||
return "copper-brown";
|
||||
if (geneDefName.Contains("Orange"))
|
||||
return "orange-red";
|
||||
if (geneDefName.Contains("Light"))
|
||||
return "light";
|
||||
if (geneDefName.Contains("Dark"))
|
||||
return "dark";
|
||||
if (geneDefName.Contains("Blue"))
|
||||
return "blue";
|
||||
if (geneDefName.Contains("Green"))
|
||||
return "green";
|
||||
if (geneDefName.Contains("Purple"))
|
||||
return "purple";
|
||||
if (geneDefName.Contains("Pink"))
|
||||
return "pink";
|
||||
if (geneDefName.Contains("Gray") || geneDefName.Contains("Grey"))
|
||||
return "gray";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetMoodFromTraits(List<Trait> traits)
|
||||
{
|
||||
if (traits == null || !traits.Any())
|
||||
@@ -310,15 +462,15 @@ namespace AIImages.Services
|
||||
itemDesc.Append(" ");
|
||||
}
|
||||
|
||||
// Материал
|
||||
if (!string.IsNullOrEmpty(item.Material))
|
||||
// Материал - используем DefName для английского названия
|
||||
if (!string.IsNullOrEmpty(item.MaterialDefName))
|
||||
{
|
||||
itemDesc.Append(item.Material.ToLower());
|
||||
itemDesc.Append(CleanDefName(item.MaterialDefName));
|
||||
itemDesc.Append(" ");
|
||||
}
|
||||
|
||||
// Название предмета
|
||||
itemDesc.Append(item.Label.ToLower());
|
||||
// Название предмета - используем DefName для английского названия
|
||||
itemDesc.Append(CleanDefName(item.DefName));
|
||||
|
||||
items.Add(itemDesc.ToString());
|
||||
}
|
||||
@@ -328,26 +480,69 @@ namespace AIImages.Services
|
||||
return apparelDesc.ToString();
|
||||
}
|
||||
|
||||
private string GetQualityTags(ArtStyle style)
|
||||
private string GetQualityTags(string styleDefName)
|
||||
{
|
||||
var baseTags = "highly detailed, professional, masterpiece, best quality";
|
||||
var styleDef = DefDatabase<ArtStyleDef>.GetNamedSilentFail(styleDefName);
|
||||
if (styleDef == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (style == ArtStyle.Realistic || style == ArtStyle.SemiRealistic)
|
||||
StringBuilder tags = new StringBuilder();
|
||||
|
||||
// Базовые теги качества
|
||||
if (styleDef.addBaseQualityTags)
|
||||
{
|
||||
return $"{baseTags}, professional photography, 8k uhd, dslr, high quality, sharp focus";
|
||||
tags.Append("highly detailed, professional, masterpiece, best quality");
|
||||
}
|
||||
else if (style == ArtStyle.Anime)
|
||||
|
||||
// Специфичные для стиля теги качества
|
||||
if (!string.IsNullOrEmpty(styleDef.qualityTags))
|
||||
{
|
||||
return $"{baseTags}, anime masterpiece, high resolution, vibrant colors";
|
||||
}
|
||||
else if (style == ArtStyle.ConceptArt)
|
||||
if (tags.Length > 0)
|
||||
{
|
||||
return $"{baseTags}, trending on artstation, professional digital art";
|
||||
tags.Append(", ");
|
||||
}
|
||||
else
|
||||
tags.Append(styleDef.qualityTags);
|
||||
}
|
||||
|
||||
return tags.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Преобразует defName в читаемый английский текст для промпта
|
||||
/// Пример: "Apparel_Pants" -> "pants", "Synthread" -> "synthread"
|
||||
/// </summary>
|
||||
private string CleanDefName(string defName)
|
||||
{
|
||||
return baseTags;
|
||||
}
|
||||
if (string.IsNullOrEmpty(defName))
|
||||
return "";
|
||||
|
||||
string cleaned = defName;
|
||||
|
||||
// Убираем распространенные префиксы RimWorld
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||
cleaned,
|
||||
"^(Apparel_|Armor_|Weapon_|Thing_)",
|
||||
"",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase
|
||||
);
|
||||
|
||||
// Разделяем CamelCase на слова
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||
cleaned,
|
||||
"([a-z])([A-Z])",
|
||||
"$1 $2"
|
||||
);
|
||||
|
||||
// Заменяем подчеркивания на пробелы
|
||||
cleaned = cleaned.Replace("_", " ");
|
||||
|
||||
// Убираем множественные пробелы
|
||||
cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"\s+", " ");
|
||||
|
||||
// Приводим к нижнему регистру и убираем лишние пробелы
|
||||
return cleaned.Trim().ToLower();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Services
|
||||
{
|
||||
@@ -7,8 +11,43 @@ namespace AIImages.Services
|
||||
/// </summary>
|
||||
public static class ColorDescriptionService
|
||||
{
|
||||
// Константы для повторяющихся строковых литералов
|
||||
private const string Green = "Green";
|
||||
private const string Orange = "Orange";
|
||||
private const string Black = "Black";
|
||||
private const string White = "White";
|
||||
|
||||
/// <summary>
|
||||
/// Получает текстовое описание цвета волос
|
||||
/// Сначала проверяет гены цвета волос, затем использует вычисленный RimWorld цвет волос
|
||||
/// </summary>
|
||||
public static string GetHairColorDescription(Pawn pawn)
|
||||
{
|
||||
if (pawn?.genes?.GenesListForReading != null)
|
||||
{
|
||||
// Ищем активные и неподавленные гены цвета волос
|
||||
var hairColorGenes = pawn
|
||||
.genes.GenesListForReading.Where(gene =>
|
||||
gene.Active && !IsGeneSuppressed(pawn, gene) && IsHairColorGene(gene.def)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
// Если есть гены цвета волос, используем их описание
|
||||
if (hairColorGenes.Any())
|
||||
{
|
||||
var hairColorGene = hairColorGenes[0];
|
||||
string geneDescription = GetHairColorGeneDescription(hairColorGene.def);
|
||||
if (!string.IsNullOrEmpty(geneDescription))
|
||||
return geneDescription;
|
||||
}
|
||||
}
|
||||
|
||||
// Иначе используем вычисленный RimWorld цвет волос
|
||||
return GetHairColorDescription(pawn?.story?.HairColor ?? Color.white);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает текстовое описание цвета волос по RGB цвету (fallback метод)
|
||||
/// </summary>
|
||||
public static string GetHairColorDescription(Color color)
|
||||
{
|
||||
@@ -32,6 +71,37 @@ namespace AIImages.Services
|
||||
|
||||
/// <summary>
|
||||
/// Получает текстовое описание цвета кожи
|
||||
/// Сначала проверяет специальные гены цвета кожи (зелёная, синяя и т.д.),
|
||||
/// затем использует вычисленный RimWorld цвет кожи
|
||||
/// </summary>
|
||||
public static string GetSkinToneDescription(Pawn pawn)
|
||||
{
|
||||
if (pawn?.genes?.GenesListForReading == null)
|
||||
return GetSkinToneDescription(pawn.story.SkinColor);
|
||||
|
||||
// Ищем активные и неподавленные гены цвета кожи (особенно специальные: зелёная, синяя, красная и т.д.)
|
||||
var skinColorGenes = pawn
|
||||
.genes.GenesListForReading.Where(gene =>
|
||||
gene.Active && !IsGeneSuppressed(pawn, gene) && IsSpecialSkinColorGene(gene.def)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
// Если есть специальные гены цвета кожи, используем их описание
|
||||
if (skinColorGenes.Any())
|
||||
{
|
||||
var specialGene = skinColorGenes[0];
|
||||
string geneDescription = GetSpecialGeneSkinTone(specialGene.def.defName);
|
||||
if (!string.IsNullOrEmpty(geneDescription))
|
||||
return geneDescription;
|
||||
}
|
||||
|
||||
// Иначе RimWorld уже определил цвет текстуры пешки на основе генов (включая уровень мелатонина)
|
||||
// Используем этот вычисленный цвет
|
||||
return GetSkinToneDescription(pawn.story.SkinColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает текстовое описание цвета кожи по RGB цвету (fallback метод)
|
||||
/// </summary>
|
||||
public static string GetSkinToneDescription(Color color)
|
||||
{
|
||||
@@ -59,6 +129,349 @@ namespace AIImages.Services
|
||||
return "very dark skin";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, является ли ген специальным геном цвета кожи (зелёная, синяя, красная и т.д.)
|
||||
/// Исключает гены меланина (Melanin1-9), так как они определяют обычный цвет кожи
|
||||
/// </summary>
|
||||
private static bool IsSpecialSkinColorGene(GeneDef geneDef)
|
||||
{
|
||||
if (geneDef == null)
|
||||
return false;
|
||||
|
||||
string geneDefName = geneDef.defName;
|
||||
|
||||
// Исключаем гены меланина - они не дают специальный цвет
|
||||
if (geneDefName.StartsWith("Skin_Melanin"))
|
||||
return false;
|
||||
|
||||
// Специальные цвета: зелёная, синяя, красная, жёлтая, фиолетовая, оранжевая, чёрная, белая, серая
|
||||
return geneDefName.StartsWith("Skin_") && HasSpecialSkinColorKeyword(geneDefName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет наличие ключевых слов специальных цветов кожи
|
||||
/// </summary>
|
||||
private static bool HasSpecialSkinColorKeyword(string geneDefName)
|
||||
{
|
||||
return geneDefName.Contains(Green)
|
||||
|| geneDefName.Contains("Blue")
|
||||
|| geneDefName.Contains("Red")
|
||||
|| geneDefName.Contains("Yellow")
|
||||
|| geneDefName.Contains("Purple")
|
||||
|| geneDefName.Contains(Orange)
|
||||
|| geneDefName.Contains(Black)
|
||||
|| geneDefName.Contains(White)
|
||||
|| geneDefName.Contains("Gray")
|
||||
|| geneDefName.Contains("Grey")
|
||||
|| geneDefName.Contains("Ink")
|
||||
|| geneDefName.Contains("Slate")
|
||||
|| geneDefName.Contains("Sheer")
|
||||
|| geneDefName.Contains("Pale")
|
||||
|| geneDefName.Contains("Deep");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает описание для специальных генов цвета кожи
|
||||
/// </summary>
|
||||
private static string GetSpecialGeneSkinTone(string geneDefName)
|
||||
{
|
||||
// Сначала проверяем точные совпадения
|
||||
string exactMatch = GetExactSkinColorMatch(geneDefName);
|
||||
if (exactMatch != null)
|
||||
return exactMatch;
|
||||
|
||||
// Затем пытаемся определить по ключевым словам
|
||||
return GetSkinColorByKeyword(geneDefName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает точное совпадение для известных генов цвета кожи
|
||||
/// </summary>
|
||||
private static string GetExactSkinColorMatch(string geneDefName)
|
||||
{
|
||||
switch (geneDefName)
|
||||
{
|
||||
case "Skin_InkBlack":
|
||||
return "ink black skin";
|
||||
case "Skin_SlateGray":
|
||||
return "slate gray skin";
|
||||
case "Skin_LightGray":
|
||||
return "light gray skin";
|
||||
case "Skin_SheerWhite":
|
||||
return "sheer white skin";
|
||||
case "Skin_Blue":
|
||||
return "blue skin";
|
||||
case "Skin_Purple":
|
||||
return "purple skin";
|
||||
case "Skin_PaleRed":
|
||||
return "pale red skin";
|
||||
case "Skin_DeepRed":
|
||||
return "deep red skin";
|
||||
case "Skin_PaleYellow":
|
||||
return "pale yellow skin";
|
||||
case "Skin_DeepYellow":
|
||||
return "deep yellow skin";
|
||||
case "Skin_Orange":
|
||||
return "orange skin";
|
||||
case "Skin_Green":
|
||||
return "green skin";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Определяет цвет кожи по ключевым словам в названии гена
|
||||
/// </summary>
|
||||
private static string GetSkinColorByKeyword(string geneDefName)
|
||||
{
|
||||
// Пытаемся определить по названию (defName всегда на английском)
|
||||
if (geneDefName.Contains(Green))
|
||||
return "green skin";
|
||||
if (geneDefName.Contains("Blue"))
|
||||
return "blue skin";
|
||||
if (geneDefName.Contains("Red"))
|
||||
return "red skin";
|
||||
if (geneDefName.Contains("Yellow"))
|
||||
return "yellow skin";
|
||||
if (geneDefName.Contains("Purple"))
|
||||
return "purple skin";
|
||||
if (geneDefName.Contains(Orange))
|
||||
return "orange skin";
|
||||
if (geneDefName.Contains(Black))
|
||||
return "black skin";
|
||||
if (geneDefName.Contains(White))
|
||||
return "white skin";
|
||||
if (geneDefName.Contains("Gray") || geneDefName.Contains("Grey"))
|
||||
return "gray skin";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, подавлен ли ген другим активным геном
|
||||
/// </summary>
|
||||
private static bool IsGeneSuppressed(Pawn pawn, Gene gene)
|
||||
{
|
||||
if (pawn?.genes == null || gene?.def == null)
|
||||
return false;
|
||||
|
||||
// В RimWorld, если ген активен, он уже учтён в системе
|
||||
// Но мы можем проверить, подавляет ли другой активный ген данный ген
|
||||
// Используем GeneUtility для проверки подавления
|
||||
try
|
||||
{
|
||||
// Проверяем все активные гены на предмет подавления данного гена
|
||||
foreach (var otherGene in pawn.genes.GenesListForReading)
|
||||
{
|
||||
if (
|
||||
otherGene.Active
|
||||
&& otherGene != gene
|
||||
&& otherGene.def != null
|
||||
&& CheckGeneSuppression(otherGene.def, gene.def)
|
||||
)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Если reflection не работает, возвращаем false
|
||||
// В RimWorld свойство Active уже учитывает подавление
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, подавляет ли один ген другой через reflection
|
||||
/// </summary>
|
||||
private static bool CheckGeneSuppression(GeneDef suppressor, GeneDef suppressed)
|
||||
{
|
||||
var suppressesField = suppressor.GetType().GetField("suppresses");
|
||||
if (suppressesField == null)
|
||||
return false;
|
||||
|
||||
var suppresses = suppressesField.GetValue(suppressor);
|
||||
if (suppresses is System.Collections.Generic.List<GeneDef> suppressesList)
|
||||
{
|
||||
return suppressesList.Contains(suppressed);
|
||||
}
|
||||
|
||||
if (suppresses is System.Collections.Generic.IEnumerable<GeneDef> suppressesEnum)
|
||||
{
|
||||
return suppressesEnum.Contains(suppressed);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, является ли ген геном цвета волос
|
||||
/// </summary>
|
||||
private static bool IsHairColorGene(GeneDef geneDef)
|
||||
{
|
||||
if (geneDef == null)
|
||||
return false;
|
||||
|
||||
string geneDefName = geneDef.defName;
|
||||
|
||||
// Проверяем стандартные паттерны для генов цвета волос
|
||||
return geneDefName.StartsWith("Hair_")
|
||||
|| geneDefName.Contains("HairColor")
|
||||
|| geneDefName.Contains("HairColour")
|
||||
|| geneDefName.Contains("HairTone")
|
||||
|| (
|
||||
geneDefName.Contains("Hair")
|
||||
&& (
|
||||
geneDefName.Contains("Color")
|
||||
|| geneDefName.Contains("Colour")
|
||||
|| geneDefName.Contains("Red")
|
||||
|| geneDefName.Contains("Blue")
|
||||
|| geneDefName.Contains(Green)
|
||||
|| geneDefName.Contains("Blond")
|
||||
|| geneDefName.Contains("Brown")
|
||||
|| geneDefName.Contains(Black)
|
||||
|| geneDefName.Contains(White)
|
||||
|| geneDefName.Contains("Light")
|
||||
|| geneDefName.Contains("Dark")
|
||||
|| geneDefName.Contains("Ginger")
|
||||
|| geneDefName.Contains("Auburn")
|
||||
|| geneDefName.Contains("Copper")
|
||||
|| geneDefName.Contains(Orange)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает описание цвета волос из гена
|
||||
/// </summary>
|
||||
private static string GetHairColorGeneDescription(GeneDef geneDef)
|
||||
{
|
||||
if (geneDef == null)
|
||||
return null;
|
||||
|
||||
string geneDefName = geneDef.defName;
|
||||
|
||||
// Сначала проверяем точные совпадения
|
||||
string exactMatch = GetExactHairColorMatch(geneDefName);
|
||||
if (exactMatch != null)
|
||||
return exactMatch;
|
||||
|
||||
// Затем пытаемся определить по ключевым словам
|
||||
return GetHairColorByKeyword(geneDefName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает точное совпадение для известных генов цвета волос
|
||||
/// </summary>
|
||||
private static string GetExactHairColorMatch(string geneDefName)
|
||||
{
|
||||
switch (geneDefName)
|
||||
{
|
||||
case "Hair_Blond":
|
||||
case "Hair_Blonde":
|
||||
return "blond hair";
|
||||
case "Hair_Brown":
|
||||
return "brown hair";
|
||||
case "Hair_Black":
|
||||
return "black hair";
|
||||
case "Hair_White":
|
||||
return "white hair";
|
||||
case "Hair_Red":
|
||||
return "red hair";
|
||||
case "Hair_Ginger":
|
||||
return "ginger hair";
|
||||
case "Hair_Auburn":
|
||||
return "auburn hair";
|
||||
case "Hair_Copper":
|
||||
case "Hair_CopperBrown":
|
||||
return "copper-brown hair";
|
||||
case "Hair_Light":
|
||||
return "light hair";
|
||||
case "Hair_Dark":
|
||||
return "dark hair";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Определяет цвет волос по ключевым словам в названии гена
|
||||
/// </summary>
|
||||
private static string GetHairColorByKeyword(string geneDefName)
|
||||
{
|
||||
// Проверяем основные цвета волос
|
||||
string basicColor = GetBasicHairColor(geneDefName);
|
||||
if (basicColor != null)
|
||||
return basicColor;
|
||||
|
||||
// Проверяем специальные цвета
|
||||
string specialColor = GetSpecialHairColor(geneDefName);
|
||||
if (specialColor != null)
|
||||
return specialColor;
|
||||
|
||||
// Проверяем оттенки
|
||||
return GetHairColorShade(geneDefName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает основной цвет волос
|
||||
/// </summary>
|
||||
private static string GetBasicHairColor(string geneDefName)
|
||||
{
|
||||
if (geneDefName.Contains("Blond"))
|
||||
return "blond hair";
|
||||
if (geneDefName.Contains("Brown"))
|
||||
return "brown hair";
|
||||
if (geneDefName.Contains(Black))
|
||||
return "black hair";
|
||||
if (geneDefName.Contains(White))
|
||||
return "white hair";
|
||||
if (geneDefName.Contains("Red"))
|
||||
return "red hair";
|
||||
if (geneDefName.Contains("Ginger"))
|
||||
return "ginger hair";
|
||||
if (geneDefName.Contains("Auburn"))
|
||||
return "auburn hair";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает специальный цвет волос
|
||||
/// </summary>
|
||||
private static string GetSpecialHairColor(string geneDefName)
|
||||
{
|
||||
if (geneDefName.Contains("Copper"))
|
||||
return "copper-brown hair";
|
||||
if (geneDefName.Contains(Orange))
|
||||
return "orange-red hair";
|
||||
if (geneDefName.Contains("Blue"))
|
||||
return "blue hair";
|
||||
if (geneDefName.Contains(Green))
|
||||
return "green hair";
|
||||
if (geneDefName.Contains("Purple"))
|
||||
return "purple hair";
|
||||
if (geneDefName.Contains("Pink"))
|
||||
return "pink hair";
|
||||
if (geneDefName.Contains("Gray") || geneDefName.Contains("Grey"))
|
||||
return "gray hair";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает оттенок волос
|
||||
/// </summary>
|
||||
private static string GetHairColorShade(string geneDefName)
|
||||
{
|
||||
if (geneDefName.Contains("Light"))
|
||||
return "light hair";
|
||||
if (geneDefName.Contains("Dark"))
|
||||
return "dark hair";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получает описание цвета одежды
|
||||
/// </summary>
|
||||
|
||||
@@ -15,6 +15,15 @@ namespace AIImages.Services
|
||||
StableDiffusionSettings settings
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Генерирует позитивный промпт на основе данных о персонаже и события
|
||||
/// </summary>
|
||||
string GeneratePositivePromptWithEvent(
|
||||
PawnAppearanceData appearanceData,
|
||||
StableDiffusionSettings settings,
|
||||
string eventDescription
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Генерирует негативный промпт на основе настроек
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AIImages.Models;
|
||||
|
||||
@@ -12,21 +13,46 @@ namespace AIImages.Services
|
||||
/// <summary>
|
||||
/// Генерирует изображение на основе запроса
|
||||
/// </summary>
|
||||
Task<GenerationResult> GenerateImageAsync(GenerationRequest request);
|
||||
Task<GenerationResult> GenerateImageAsync(
|
||||
GenerationRequest request,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Получает прогресс текущей генерации
|
||||
/// </summary>
|
||||
Task<GenerationProgress> GetProgressAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет доступность API
|
||||
/// </summary>
|
||||
Task<bool> CheckApiAvailability(string apiEndpoint);
|
||||
Task<bool> CheckApiAvailability(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Получает список доступных моделей с API
|
||||
/// </summary>
|
||||
Task<List<string>> GetAvailableModels(string apiEndpoint);
|
||||
Task<List<string>> GetAvailableModels(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Получает список доступных сэмплеров
|
||||
/// </summary>
|
||||
Task<List<string>> GetAvailableSamplers(string apiEndpoint);
|
||||
Task<List<string>> GetAvailableSamplers(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Получает список доступных schedulers
|
||||
/// </summary>
|
||||
Task<List<string>> GetAvailableSchedulers(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using AIImages.Models;
|
||||
using RimWorld;
|
||||
@@ -25,9 +26,35 @@ namespace AIImages.Services
|
||||
BodyType = pawn.story.bodyType?.defName,
|
||||
SkinColor = pawn.story.SkinColor,
|
||||
HairStyle = pawn.story.hairDef?.label,
|
||||
HairDefName = pawn.story.hairDef?.defName,
|
||||
HairColor = pawn.story.HairColor,
|
||||
};
|
||||
|
||||
// Извлекаем гены цвета кожи
|
||||
if (pawn.genes?.GenesListForReading != null)
|
||||
{
|
||||
var skinColorGenes = pawn
|
||||
.genes.GenesListForReading
|
||||
.Where(gene => gene.Active && IsSkinColorGene(gene.def) && !IsGeneSuppressed(pawn, gene))
|
||||
.ToList();
|
||||
|
||||
foreach (var gene in skinColorGenes)
|
||||
{
|
||||
data.SkinColorGeneDefNames.Add(gene.def.defName);
|
||||
}
|
||||
|
||||
// Извлекаем гены цвета волос
|
||||
var hairColorGenes = pawn
|
||||
.genes.GenesListForReading
|
||||
.Where(gene => gene.Active && IsHairColorGene(gene.def) && !IsGeneSuppressed(pawn, gene))
|
||||
.ToList();
|
||||
|
||||
foreach (var gene in hairColorGenes)
|
||||
{
|
||||
data.HairColorGeneDefNames.Add(gene.def.defName);
|
||||
}
|
||||
}
|
||||
|
||||
// Извлекаем черты характера
|
||||
if (pawn.story.traits?.allTraits != null)
|
||||
{
|
||||
@@ -42,7 +69,9 @@ namespace AIImages.Services
|
||||
var apparelData = new ApparelData
|
||||
{
|
||||
Label = apparel.def.label,
|
||||
DefName = apparel.def.defName,
|
||||
Material = apparel.Stuff?.label,
|
||||
MaterialDefName = apparel.Stuff?.defName,
|
||||
Color = apparel.DrawColor,
|
||||
LayerType = apparel.def.apparel?.LastLayer.ToString(),
|
||||
Durability = apparel.HitPoints,
|
||||
@@ -82,12 +111,10 @@ namespace AIImages.Services
|
||||
);
|
||||
}
|
||||
|
||||
// Цвет кожи (с умным описанием)
|
||||
if (pawn.story.SkinColor != null)
|
||||
// Цвет кожи (с умным описанием, сначала проверяем гены)
|
||||
string skinDescription = ColorDescriptionService.GetSkinToneDescription(pawn);
|
||||
if (!string.IsNullOrEmpty(skinDescription))
|
||||
{
|
||||
string skinDescription = ColorDescriptionService.GetSkinToneDescription(
|
||||
pawn.story.SkinColor
|
||||
);
|
||||
sb.AppendLine("AIImages.Appearance.SkinTone".Translate(skinDescription));
|
||||
}
|
||||
|
||||
@@ -95,11 +122,10 @@ namespace AIImages.Services
|
||||
if (pawn.story.hairDef != null)
|
||||
{
|
||||
sb.AppendLine("AIImages.Appearance.Hairstyle".Translate(pawn.story.hairDef.label));
|
||||
if (pawn.story.HairColor != null)
|
||||
// Используем метод с проверкой генов
|
||||
string hairColorDescription = ColorDescriptionService.GetHairColorDescription(pawn);
|
||||
if (!string.IsNullOrEmpty(hairColorDescription))
|
||||
{
|
||||
string hairColorDescription = ColorDescriptionService.GetHairColorDescription(
|
||||
pawn.story.HairColor
|
||||
);
|
||||
sb.AppendLine(
|
||||
"AIImages.Appearance.HairColorDesc".Translate(hairColorDescription)
|
||||
);
|
||||
@@ -179,5 +205,114 @@ namespace AIImages.Services
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, является ли ген геном цвета кожи
|
||||
/// </summary>
|
||||
private bool IsSkinColorGene(GeneDef geneDef)
|
||||
{
|
||||
if (geneDef == null)
|
||||
return false;
|
||||
|
||||
string geneDefName = geneDef.defName;
|
||||
return geneDefName.StartsWith("Skin_")
|
||||
|| geneDefName.Contains("SkinColor")
|
||||
|| geneDefName.Contains("SkinTone");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, является ли ген геном цвета волос
|
||||
/// </summary>
|
||||
private bool IsHairColorGene(GeneDef geneDef)
|
||||
{
|
||||
if (geneDef == null)
|
||||
return false;
|
||||
|
||||
string geneDefName = geneDef.defName;
|
||||
return geneDefName.StartsWith("Hair_")
|
||||
|| geneDefName.Contains("HairColor")
|
||||
|| geneDefName.Contains("HairColour")
|
||||
|| geneDefName.Contains("HairTone")
|
||||
|| (geneDefName.Contains("Hair") && (
|
||||
geneDefName.Contains("Color")
|
||||
|| geneDefName.Contains("Colour")
|
||||
|| geneDefName.Contains("Red")
|
||||
|| geneDefName.Contains("Blue")
|
||||
|| geneDefName.Contains("Green")
|
||||
|| geneDefName.Contains("Blond")
|
||||
|| geneDefName.Contains("Brown")
|
||||
|| geneDefName.Contains("Black")
|
||||
|| geneDefName.Contains("White")
|
||||
|| geneDefName.Contains("Light")
|
||||
|| geneDefName.Contains("Dark")
|
||||
|| geneDefName.Contains("Ginger")
|
||||
|| geneDefName.Contains("Auburn")
|
||||
|| geneDefName.Contains("Copper")
|
||||
|| geneDefName.Contains("Orange")
|
||||
));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, подавлен ли ген другим активным геном
|
||||
/// </summary>
|
||||
private bool IsGeneSuppressed(Pawn pawn, Gene gene)
|
||||
{
|
||||
if (pawn?.genes == null || gene?.def == null)
|
||||
return false;
|
||||
|
||||
// В RimWorld, если ген активен, он уже учтён в системе
|
||||
// Но мы можем проверить, подавляет ли другой активный ген данный ген
|
||||
try
|
||||
{
|
||||
// Проверяем все активные гены на предмет подавления данного гена
|
||||
foreach (var otherGene in pawn.genes.GenesListForReading)
|
||||
{
|
||||
if (otherGene.Active && otherGene != gene && otherGene.def != null)
|
||||
{
|
||||
// Проверяем через reflection, так как свойство suppresses может быть приватным
|
||||
var suppressesField = otherGene.def.GetType().GetField("suppresses");
|
||||
if (suppressesField != null)
|
||||
{
|
||||
var suppresses = suppressesField.GetValue(otherGene.def);
|
||||
if (suppresses is System.Collections.Generic.List<GeneDef> suppressesList)
|
||||
{
|
||||
if (suppressesList.Contains(gene.def))
|
||||
return true;
|
||||
}
|
||||
else if (suppresses is System.Collections.Generic.IEnumerable<GeneDef> suppressesEnum)
|
||||
{
|
||||
if (suppressesEnum.Contains(gene.def))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Также проверяем свойство, если оно публичное
|
||||
var suppressesProperty = otherGene.def.GetType().GetProperty("suppresses");
|
||||
if (suppressesProperty != null)
|
||||
{
|
||||
var suppresses = suppressesProperty.GetValue(otherGene.def);
|
||||
if (suppresses is System.Collections.Generic.List<GeneDef> suppressesList)
|
||||
{
|
||||
if (suppressesList.Contains(gene.def))
|
||||
return true;
|
||||
}
|
||||
else if (suppresses is System.Collections.Generic.IEnumerable<GeneDef> suppressesEnum)
|
||||
{
|
||||
if (suppressesEnum.Contains(gene.def))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Если reflection не работает, возвращаем false
|
||||
// В RimWorld свойство Active уже учитывает подавление
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
83
Source/AIImages/Services/ServiceContainer.cs
Normal file
83
Source/AIImages/Services/ServiceContainer.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using AIImages.Settings;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Простой DI контейнер для управления зависимостями мода
|
||||
/// Используется вместо статического Service Locator паттерна
|
||||
/// </summary>
|
||||
public class ServiceContainer : IDisposable
|
||||
{
|
||||
private bool _disposed;
|
||||
|
||||
// Сервисы
|
||||
public IPawnDescriptionService PawnDescriptionService { get; }
|
||||
public IPromptGeneratorService PromptGeneratorService { get; }
|
||||
public IStableDiffusionApiService ApiService { get; private set; }
|
||||
|
||||
// Настройки
|
||||
public AIImagesModSettings Settings { get; }
|
||||
|
||||
public ServiceContainer(AIImagesModSettings settings)
|
||||
{
|
||||
if (settings == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(settings));
|
||||
}
|
||||
|
||||
Settings = settings;
|
||||
|
||||
// Инициализируем сервисы с внедрением зависимостей
|
||||
PawnDescriptionService = new PawnDescriptionService();
|
||||
PromptGeneratorService = new AdvancedPromptGenerator();
|
||||
|
||||
// Создаем API сервис с текущими настройками
|
||||
RecreateApiService();
|
||||
|
||||
Log.Message("[AI Images] ServiceContainer initialized successfully");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пересоздает API сервис с новыми настройками (например, когда изменился endpoint)
|
||||
/// </summary>
|
||||
public void RecreateApiService()
|
||||
{
|
||||
// Освобождаем старый сервис, если он был
|
||||
if (ApiService is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
// Создаем новый с актуальными настройками
|
||||
ApiService = new StableDiffusionNetAdapter(Settings.apiEndpoint, Settings.savePath);
|
||||
|
||||
Log.Message($"[AI Images] API service recreated with endpoint: {Settings.apiEndpoint}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, нужно ли пересоздать API сервис (например, если изменился endpoint)
|
||||
/// </summary>
|
||||
public bool ShouldRecreateApiService(string newEndpoint)
|
||||
{
|
||||
// Можно добавить более сложную логику проверки
|
||||
return Settings.apiEndpoint != newEndpoint;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
// Освобождаем ресурсы API сервиса
|
||||
if (ApiService is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
Log.Message("[AI Images] ServiceContainer disposed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AIImages.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Сервис для работы с Stable Diffusion API (AUTOMATIC1111 WebUI)
|
||||
/// </summary>
|
||||
public class StableDiffusionApiService : IStableDiffusionApiService
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly string saveFolderPath;
|
||||
|
||||
public StableDiffusionApiService(string savePath = "AIImages/Generated")
|
||||
{
|
||||
httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
|
||||
// Определяем путь для сохранения
|
||||
saveFolderPath = Path.Combine(GenFilePaths.SaveDataFolderPath, savePath);
|
||||
|
||||
// Создаем папку, если не существует
|
||||
if (!Directory.Exists(saveFolderPath))
|
||||
{
|
||||
Directory.CreateDirectory(saveFolderPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GenerationResult> GenerateImageAsync(GenerationRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.Message(
|
||||
$"[AI Images] Starting image generation with prompt: {request.Prompt.Substring(0, Math.Min(50, request.Prompt.Length))}..."
|
||||
);
|
||||
|
||||
// Формируем JSON запрос для AUTOMATIC1111 API
|
||||
var apiRequest = new
|
||||
{
|
||||
prompt = request.Prompt,
|
||||
negative_prompt = request.NegativePrompt,
|
||||
steps = request.Steps,
|
||||
cfg_scale = request.CfgScale,
|
||||
width = request.Width,
|
||||
height = request.Height,
|
||||
sampler_name = request.Sampler,
|
||||
seed = request.Seed,
|
||||
save_images = false,
|
||||
send_images = true,
|
||||
};
|
||||
|
||||
string jsonRequest = JsonConvert.SerializeObject(apiRequest);
|
||||
var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json");
|
||||
|
||||
// Отправляем запрос
|
||||
string endpoint = $"{request.Model}/sdapi/v1/txt2img";
|
||||
HttpResponseMessage response = await httpClient.PostAsync(endpoint, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string errorContent = await response.Content.ReadAsStringAsync();
|
||||
Log.Error(
|
||||
$"[AI Images] API request failed: {response.StatusCode} - {errorContent}"
|
||||
);
|
||||
return GenerationResult.Failure($"API Error: {response.StatusCode}");
|
||||
}
|
||||
|
||||
string jsonResponse = await response.Content.ReadAsStringAsync();
|
||||
var apiResponse = JsonConvert.DeserializeObject<Txt2ImgResponse>(jsonResponse);
|
||||
|
||||
if (apiResponse?.images == null || apiResponse.images.Length == 0)
|
||||
{
|
||||
return GenerationResult.Failure("No images returned from API");
|
||||
}
|
||||
|
||||
// Декодируем изображение из base64
|
||||
byte[] imageData = Convert.FromBase64String(apiResponse.images[0]);
|
||||
|
||||
// Сохраняем изображение
|
||||
string fileName = $"pawn_{DateTime.Now:yyyyMMdd_HHmmss}.png";
|
||||
string fullPath = Path.Combine(saveFolderPath, fileName);
|
||||
await File.WriteAllBytesAsync(fullPath, imageData);
|
||||
|
||||
Log.Message($"[AI Images] Image generated successfully and saved to: {fullPath}");
|
||||
|
||||
return GenerationResult.SuccessResult(imageData, fullPath, request);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return GenerationResult.Failure("Request timeout. Generation took too long.");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Log.Error($"[AI Images] HTTP error: {ex.Message}");
|
||||
return GenerationResult.Failure($"Connection error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Unexpected error: {ex.Message}\n{ex.StackTrace}");
|
||||
return GenerationResult.Failure($"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CheckApiAvailability(string apiEndpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models";
|
||||
HttpResponseMessage response = await httpClient.GetAsync(endpoint);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[AI Images] API check failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableModels(string apiEndpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models";
|
||||
HttpResponseMessage response = await httpClient.GetAsync(endpoint);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new List<string>();
|
||||
|
||||
string jsonResponse = await response.Content.ReadAsStringAsync();
|
||||
var models = JsonConvert.DeserializeObject<List<SdModel>>(jsonResponse);
|
||||
|
||||
var modelNames = new List<string>();
|
||||
if (models != null)
|
||||
{
|
||||
foreach (var model in models)
|
||||
{
|
||||
modelNames.Add(model.title ?? model.model_name);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Message($"[AI Images] Found {modelNames.Count} models");
|
||||
return modelNames;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Failed to load models: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableSamplers(string apiEndpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
string endpoint = $"{apiEndpoint}/sdapi/v1/samplers";
|
||||
HttpResponseMessage response = await httpClient.GetAsync(endpoint);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return GetDefaultSamplers();
|
||||
|
||||
string jsonResponse = await response.Content.ReadAsStringAsync();
|
||||
var samplers = JsonConvert.DeserializeObject<List<SdSampler>>(jsonResponse);
|
||||
|
||||
var samplerNames = new List<string>();
|
||||
if (samplers != null)
|
||||
{
|
||||
foreach (var sampler in samplers)
|
||||
{
|
||||
samplerNames.Add(sampler.name);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Message($"[AI Images] Found {samplerNames.Count} samplers");
|
||||
return samplerNames;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[AI Images] Failed to load samplers: {ex.Message}");
|
||||
return GetDefaultSamplers();
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> GetDefaultSamplers()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"Euler a",
|
||||
"Euler",
|
||||
"LMS",
|
||||
"Heun",
|
||||
"DPM2",
|
||||
"DPM2 a",
|
||||
"DPM++ 2S a",
|
||||
"DPM++ 2M",
|
||||
"DPM++ SDE",
|
||||
"DPM fast",
|
||||
"DPM adaptive",
|
||||
"LMS Karras",
|
||||
"DPM2 Karras",
|
||||
"DPM2 a Karras",
|
||||
"DPM++ 2S a Karras",
|
||||
"DPM++ 2M Karras",
|
||||
"DPM++ SDE Karras",
|
||||
"DDIM",
|
||||
"PLMS",
|
||||
};
|
||||
}
|
||||
|
||||
// Вспомогательные классы для десериализации JSON ответов
|
||||
#pragma warning disable S3459, S1144 // Properties set by JSON deserializer
|
||||
private sealed class Txt2ImgResponse
|
||||
{
|
||||
public string[] images { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SdModel
|
||||
{
|
||||
public string title { get; set; }
|
||||
public string model_name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SdSampler
|
||||
{
|
||||
public string name { get; set; }
|
||||
}
|
||||
#pragma warning restore S3459, S1144
|
||||
}
|
||||
}
|
||||
352
Source/AIImages/Services/StableDiffusionNetAdapter.cs
Normal file
352
Source/AIImages/Services/StableDiffusionNetAdapter.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AIImages.Models;
|
||||
using StableDiffusionNet;
|
||||
using StableDiffusionNet.Exceptions;
|
||||
using StableDiffusionNet.Interfaces;
|
||||
using StableDiffusionNet.Models.Requests;
|
||||
using Verse;
|
||||
|
||||
namespace AIImages.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Адаптер для Stable Diffusion API (AUTOMATIC1111 WebUI)
|
||||
/// Использует библиотеку StableDiffusionNet для работы с API
|
||||
/// </summary>
|
||||
public class StableDiffusionNetAdapter : IStableDiffusionApiService, IDisposable
|
||||
{
|
||||
private readonly IStableDiffusionClient _client;
|
||||
private readonly string _saveFolderPath;
|
||||
private bool _disposed;
|
||||
|
||||
public StableDiffusionNetAdapter(string apiEndpoint, string savePath = "AIImages/Generated")
|
||||
{
|
||||
if (string.IsNullOrEmpty(apiEndpoint))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"API endpoint cannot be null or empty",
|
||||
nameof(apiEndpoint)
|
||||
);
|
||||
}
|
||||
|
||||
// Определяем путь для сохранения
|
||||
_saveFolderPath = Path.Combine(GenFilePaths.SaveDataFolderPath, savePath);
|
||||
|
||||
// Создаем папку, если не существует
|
||||
if (!Directory.Exists(_saveFolderPath))
|
||||
{
|
||||
Directory.CreateDirectory(_saveFolderPath);
|
||||
}
|
||||
|
||||
// Создаем клиент StableDiffusion используя Builder
|
||||
_client = new StableDiffusionClientBuilder()
|
||||
.WithBaseUrl(apiEndpoint)
|
||||
.WithTimeout(300) // 5 минут в секундах
|
||||
.WithRetry(retryCount: 3, retryDelayMilliseconds: 1000)
|
||||
.Build();
|
||||
|
||||
Log.Message(
|
||||
$"[AI Images] StableDiffusion adapter initialized with endpoint: {apiEndpoint}"
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<GenerationResult> GenerateImageAsync(
|
||||
GenerationRequest request,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return GenerationResult.Failure("Request cannot be null");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Log.Message(
|
||||
$"[AI Images] Starting image generation with prompt: {request.Prompt.Substring(0, Math.Min(50, request.Prompt.Length))}..."
|
||||
);
|
||||
|
||||
// Маппируем наш запрос на запрос библиотеки StableDiffusionNet
|
||||
var sdRequest = new TextToImageRequest
|
||||
{
|
||||
Prompt = request.Prompt,
|
||||
NegativePrompt = request.NegativePrompt,
|
||||
Steps = request.Steps,
|
||||
CfgScale = request.CfgScale,
|
||||
Width = request.Width,
|
||||
Height = request.Height,
|
||||
SamplerName = request.Sampler,
|
||||
Scheduler = request.Scheduler,
|
||||
Seed = request.Seed,
|
||||
SaveImages = request.SaveImagesToServer, // Сохранять ли изображения на сервере
|
||||
};
|
||||
|
||||
// Выполняем запрос через библиотеку (с встроенной retry логикой)
|
||||
var response = await _client.TextToImage.GenerateAsync(
|
||||
sdRequest,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (response?.Images == null || response.Images.Count == 0)
|
||||
{
|
||||
return GenerationResult.Failure("No images returned from API");
|
||||
}
|
||||
|
||||
// Декодируем изображение из base64
|
||||
byte[] imageData = Convert.FromBase64String(response.Images[0]);
|
||||
|
||||
// Сохраняем изображение
|
||||
string fileName = $"pawn_{DateTime.Now:yyyyMMdd_HHmmss}.png";
|
||||
string fullPath = Path.Combine(_saveFolderPath, fileName);
|
||||
await File.WriteAllBytesAsync(fullPath, imageData);
|
||||
|
||||
Log.Message($"[AI Images] Image generated successfully and saved to: {fullPath}");
|
||||
|
||||
return GenerationResult.SuccessResult(imageData, fullPath, request);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Log.Warning("[AI Images] Request timeout. Generation took too long.");
|
||||
return GenerationResult.Failure("Request timeout. Generation took too long.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.Message("[AI Images] Image generation was cancelled");
|
||||
return GenerationResult.Failure("Generation cancelled");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Log.Error($"[AI Images] HTTP error: {ex.Message}");
|
||||
return GenerationResult.Failure($"Connection error: {ex.Message}");
|
||||
}
|
||||
catch (StableDiffusionException ex)
|
||||
{
|
||||
Log.Error($"[AI Images] StableDiffusion API error: {ex.Message}");
|
||||
return GenerationResult.Failure($"API Error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Unexpected error: {ex.Message}\n{ex.StackTrace}");
|
||||
return GenerationResult.Failure($"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GenerationProgress> GetProgressAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
try
|
||||
{
|
||||
// Используем Progress сервис библиотеки
|
||||
var progress = await _client.Progress.GetProgressAsync(cancellationToken);
|
||||
|
||||
// Маппируем на наш тип
|
||||
return new GenerationProgress
|
||||
{
|
||||
Progress = progress.Progress,
|
||||
CurrentStep = progress.State?.SamplingStep ?? 0,
|
||||
TotalSteps = progress.State?.SamplingSteps ?? 0,
|
||||
EtaRelative = progress.EtaRelative,
|
||||
IsActive = progress.Progress > 0 && progress.Progress < 1.0,
|
||||
CurrentImage = progress.CurrentImage, // Превью текущего состояния генерации
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[AI Images] Failed to get progress: {ex.Message}");
|
||||
// Возвращаем пустой прогресс при ошибке
|
||||
return new GenerationProgress
|
||||
{
|
||||
Progress = 0,
|
||||
CurrentStep = 0,
|
||||
TotalSteps = 0,
|
||||
EtaRelative = 0,
|
||||
IsActive = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CheckApiAvailability(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
try
|
||||
{
|
||||
// Используем встроенный метод HealthCheckAsync библиотеки
|
||||
var healthCheck = await _client.HealthCheckAsync(cancellationToken);
|
||||
return healthCheck.IsHealthy;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[AI Images] API check failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableModels(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
try
|
||||
{
|
||||
// Используем Models сервис библиотеки
|
||||
var models = await _client.Models.GetModelsAsync(cancellationToken);
|
||||
|
||||
var modelNames = new List<string>();
|
||||
if (models != null)
|
||||
{
|
||||
foreach (var model in models)
|
||||
{
|
||||
// Используем Title или ModelName в зависимости от того, что доступно
|
||||
modelNames.Add(model.Title ?? model.ModelName);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Message($"[AI Images] Found {modelNames.Count} models");
|
||||
return modelNames;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Failed to load models: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableSamplers(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
try
|
||||
{
|
||||
// Используем Samplers сервис библиотеки
|
||||
var samplers = await _client.Samplers.GetSamplersAsync(cancellationToken);
|
||||
|
||||
var samplerNames = new List<string>();
|
||||
if (samplers != null)
|
||||
{
|
||||
foreach (var sampler in samplers)
|
||||
{
|
||||
samplerNames.Add(sampler.Name);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Message($"[AI Images] Found {samplerNames.Count} samplers");
|
||||
return samplerNames;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[AI Images] Failed to load samplers: {ex.Message}");
|
||||
return GetDefaultSamplers();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableSchedulers(
|
||||
string apiEndpoint,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
try
|
||||
{
|
||||
// Используем Schedulers сервис библиотеки (доступен с версии 1.1.1)
|
||||
var schedulers = await _client.Schedulers.GetSchedulersAsync(cancellationToken);
|
||||
|
||||
var schedulerNames = new List<string>();
|
||||
if (schedulers != null)
|
||||
{
|
||||
foreach (var scheduler in schedulers)
|
||||
{
|
||||
schedulerNames.Add(scheduler.Name);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Message($"[AI Images] Found {schedulerNames.Count} schedulers");
|
||||
return schedulerNames;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[AI Images] Failed to load schedulers: {ex.Message}");
|
||||
return GetDefaultSchedulers();
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> GetDefaultSamplers()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"Euler a",
|
||||
"Euler",
|
||||
"LMS",
|
||||
"Heun",
|
||||
"DPM2",
|
||||
"DPM2 a",
|
||||
"DPM++ 2S a",
|
||||
"DPM++ 2M",
|
||||
"DPM++ SDE",
|
||||
"DPM fast",
|
||||
"DPM adaptive",
|
||||
"LMS Karras",
|
||||
"DPM2 Karras",
|
||||
"DPM2 a Karras",
|
||||
"DPM++ 2S a Karras",
|
||||
"DPM++ 2M Karras",
|
||||
"DPM++ SDE Karras",
|
||||
"DDIM",
|
||||
"PLMS",
|
||||
};
|
||||
}
|
||||
|
||||
private List<string> GetDefaultSchedulers()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"Automatic",
|
||||
"Uniform",
|
||||
"Karras",
|
||||
"Exponential",
|
||||
"Polyexponential",
|
||||
"SGM Uniform",
|
||||
};
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(StableDiffusionNetAdapter));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
// Dispose клиента StableDiffusion
|
||||
if (_client is IDisposable disposableClient)
|
||||
{
|
||||
disposableClient.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using AIImages.Models;
|
||||
using Verse;
|
||||
|
||||
@@ -13,6 +14,17 @@ namespace AIImages.Settings
|
||||
public string apiEndpoint = "http://127.0.0.1:7860";
|
||||
public string selectedModel = "";
|
||||
public string selectedSampler = "Euler a";
|
||||
public string selectedScheduler = "Automatic"; // С большой буквы для API
|
||||
|
||||
// Кэшированные списки из API (не сохраняются)
|
||||
[Unsaved]
|
||||
public List<string> availableModels = new List<string>();
|
||||
|
||||
[Unsaved]
|
||||
public List<string> availableSamplers = new List<string>();
|
||||
|
||||
[Unsaved]
|
||||
public List<string> availableSchedulers = new List<string>();
|
||||
|
||||
// Настройки генерации
|
||||
public int steps = 30;
|
||||
@@ -26,23 +38,28 @@ namespace AIImages.Settings
|
||||
public string baseNegativePrompt =
|
||||
"ugly, deformed, low quality, blurry, bad anatomy, worst quality";
|
||||
|
||||
// Художественный стиль
|
||||
public ArtStyle artStyle = ArtStyle.Realistic;
|
||||
public ShotType shotType = ShotType.Portrait;
|
||||
// Художественный стиль (defName)
|
||||
public string artStyleDefName = "ArtStyle_Realistic";
|
||||
|
||||
// Путь для сохранения
|
||||
public string savePath = "AIImages/Generated";
|
||||
|
||||
// Тип генерации
|
||||
public AIImages.Models.ImageType imageType = AIImages.Models.ImageType.Portrait;
|
||||
|
||||
// Флаги
|
||||
public bool autoLoadModels = true;
|
||||
public bool showTechnicalInfo = true;
|
||||
public bool saveGenerationHistory = true;
|
||||
public bool saveImagesToServer = false;
|
||||
public bool enableDebugLogs = false;
|
||||
|
||||
public override void ExposeData()
|
||||
{
|
||||
Scribe_Values.Look(ref apiEndpoint, "apiEndpoint", "http://127.0.0.1:7860");
|
||||
Scribe_Values.Look(ref selectedModel, "selectedModel", "");
|
||||
Scribe_Values.Look(ref selectedSampler, "selectedSampler", "Euler a");
|
||||
Scribe_Values.Look(ref selectedScheduler, "selectedScheduler", "Automatic");
|
||||
|
||||
Scribe_Values.Look(ref steps, "steps", 30);
|
||||
Scribe_Values.Look(ref cfgScale, "cfgScale", 7.5f);
|
||||
@@ -57,14 +74,17 @@ namespace AIImages.Settings
|
||||
"ugly, deformed, low quality, blurry, bad anatomy, worst quality"
|
||||
);
|
||||
|
||||
Scribe_Values.Look(ref artStyle, "artStyle", ArtStyle.Realistic);
|
||||
Scribe_Values.Look(ref shotType, "shotType", ShotType.Portrait);
|
||||
Scribe_Values.Look(ref artStyleDefName, "artStyleDefName", "ArtStyle_Realistic");
|
||||
|
||||
Scribe_Values.Look(ref savePath, "savePath", "AIImages/Generated");
|
||||
|
||||
Scribe_Values.Look(ref imageType, "imageType", AIImages.Models.ImageType.Portrait);
|
||||
|
||||
Scribe_Values.Look(ref autoLoadModels, "autoLoadModels", true);
|
||||
Scribe_Values.Look(ref showTechnicalInfo, "showTechnicalInfo", true);
|
||||
Scribe_Values.Look(ref saveGenerationHistory, "saveGenerationHistory", true);
|
||||
Scribe_Values.Look(ref saveImagesToServer, "saveImagesToServer", false);
|
||||
Scribe_Values.Look(ref enableDebugLogs, "enableDebugLogs", false);
|
||||
|
||||
base.ExposeData();
|
||||
}
|
||||
@@ -81,12 +101,13 @@ namespace AIImages.Settings
|
||||
Width = width,
|
||||
Height = height,
|
||||
Sampler = selectedSampler,
|
||||
Scheduler = selectedScheduler,
|
||||
Seed = seed,
|
||||
Model = selectedModel,
|
||||
ArtStyle = artStyle,
|
||||
ShotType = shotType,
|
||||
ArtStyleDefName = artStyleDefName,
|
||||
PositivePrompt = basePositivePrompt,
|
||||
NegativePrompt = baseNegativePrompt,
|
||||
ImageType = imageType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AIImages.Helpers;
|
||||
using AIImages.Models;
|
||||
using AIImages.Services;
|
||||
using AIImages.Settings;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
@@ -19,23 +21,46 @@ namespace AIImages
|
||||
private static string widthBuffer;
|
||||
private static string heightBuffer;
|
||||
|
||||
public static void DoSettingsWindowContents(Rect inRect, AIImagesModSettings settings)
|
||||
public static void DoSettingsWindowContents(
|
||||
Rect inRect,
|
||||
AIImagesModSettings settings,
|
||||
ServiceContainer serviceContainer
|
||||
)
|
||||
{
|
||||
InitializeBuffers(settings);
|
||||
|
||||
Listing_Standard listingStandard = new Listing_Standard();
|
||||
Rect viewRect = new Rect(0f, 0f, inRect.width - 20f, 1250f);
|
||||
|
||||
Widgets.BeginScrollView(inRect, ref scrollPosition, viewRect);
|
||||
listingStandard.Begin(viewRect);
|
||||
|
||||
DrawApiSettings(listingStandard, settings, serviceContainer);
|
||||
DrawGenerationSettings(listingStandard, settings);
|
||||
DrawSamplerSchedulerSettings(listingStandard, settings);
|
||||
DrawPromptsSettings(listingStandard, settings);
|
||||
DrawOptionsSettings(listingStandard, settings);
|
||||
|
||||
listingStandard.End();
|
||||
Widgets.EndScrollView();
|
||||
}
|
||||
|
||||
private static void InitializeBuffers(AIImagesModSettings settings)
|
||||
{
|
||||
// Инициализируем буферы при первом вызове
|
||||
if (string.IsNullOrEmpty(stepsBuffer))
|
||||
{
|
||||
stepsBuffer = settings.steps.ToString();
|
||||
widthBuffer = settings.width.ToString();
|
||||
heightBuffer = settings.height.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
Listing_Standard listingStandard = new Listing_Standard();
|
||||
Rect viewRect = new Rect(0f, 0f, inRect.width - 20f, 1200f);
|
||||
|
||||
Widgets.BeginScrollView(inRect, ref scrollPosition, viewRect);
|
||||
listingStandard.Begin(viewRect);
|
||||
|
||||
// === API Settings ===
|
||||
private static void DrawApiSettings(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings,
|
||||
ServiceContainer serviceContainer
|
||||
)
|
||||
{
|
||||
listingStandard.Label(
|
||||
"AIImages.Settings.ApiSection".Translate(),
|
||||
-1f,
|
||||
@@ -43,25 +68,83 @@ namespace AIImages
|
||||
);
|
||||
listingStandard.GapLine();
|
||||
|
||||
string oldEndpoint = settings.apiEndpoint;
|
||||
listingStandard.Label("AIImages.Settings.ApiEndpoint".Translate() + ":");
|
||||
settings.apiEndpoint = listingStandard.TextEntry(settings.apiEndpoint);
|
||||
listingStandard.Gap(8f);
|
||||
|
||||
// Кнопка проверки подключения
|
||||
// Если endpoint изменился, пересоздаем API сервис
|
||||
if (oldEndpoint != settings.apiEndpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
serviceContainer.RecreateApiService();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[AI Images] Failed to recreate API service: {ex.Message}");
|
||||
Messages.Message(
|
||||
"Failed to update API endpoint. Check the log.",
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (listingStandard.ButtonText("AIImages.Settings.TestConnection".Translate()))
|
||||
{
|
||||
_ = TestApiConnection(settings.apiEndpoint);
|
||||
TestApiConnection(serviceContainer.ApiService, settings.apiEndpoint);
|
||||
}
|
||||
|
||||
// Кнопка загрузки моделей
|
||||
if (listingStandard.ButtonText("AIImages.Settings.LoadModels".Translate()))
|
||||
if (listingStandard.ButtonText("AIImages.Settings.LoadFromApi".Translate()))
|
||||
{
|
||||
_ = LoadModelsFromApi(settings);
|
||||
LoadAllFromApi(serviceContainer.ApiService, settings);
|
||||
}
|
||||
|
||||
DrawModelDropdown(listingStandard, settings);
|
||||
|
||||
listingStandard.Gap(12f);
|
||||
}
|
||||
|
||||
// === Generation Settings ===
|
||||
private static void DrawModelDropdown(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
if (
|
||||
listingStandard.ButtonTextLabeled(
|
||||
"AIImages.Settings.Model".Translate(),
|
||||
string.IsNullOrEmpty(settings.selectedModel)
|
||||
? "AIImages.Settings.NoModelSelected".Translate()
|
||||
: settings.selectedModel
|
||||
)
|
||||
)
|
||||
{
|
||||
List<FloatMenuOption> modelOptions = new List<FloatMenuOption>();
|
||||
if (!settings.availableModels.Any())
|
||||
{
|
||||
modelOptions.Add(
|
||||
new FloatMenuOption("AIImages.Settings.LoadModelsFirst".Translate(), null)
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string model in settings.availableModels)
|
||||
{
|
||||
string localModel = model;
|
||||
modelOptions.Add(
|
||||
new FloatMenuOption(model, () => settings.selectedModel = localModel)
|
||||
);
|
||||
}
|
||||
}
|
||||
Find.WindowStack.Add(new FloatMenu(modelOptions));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawGenerationSettings(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
listingStandard.Label(
|
||||
"AIImages.Settings.GenerationSection".Translate(),
|
||||
-1f,
|
||||
@@ -69,59 +152,68 @@ namespace AIImages
|
||||
);
|
||||
listingStandard.GapLine();
|
||||
|
||||
// Art Style
|
||||
if (
|
||||
listingStandard.ButtonTextLabeled(
|
||||
"AIImages.Settings.ArtStyle".Translate(),
|
||||
settings.artStyle.ToString()
|
||||
)
|
||||
)
|
||||
{
|
||||
List<FloatMenuOption> styleOptions = new List<FloatMenuOption>();
|
||||
foreach (ArtStyle style in Enum.GetValues(typeof(ArtStyle)))
|
||||
{
|
||||
ArtStyle localStyle = style;
|
||||
styleOptions.Add(
|
||||
new FloatMenuOption(style.ToString(), () => settings.artStyle = localStyle)
|
||||
);
|
||||
}
|
||||
Find.WindowStack.Add(new FloatMenu(styleOptions));
|
||||
}
|
||||
|
||||
// Shot Type
|
||||
if (
|
||||
listingStandard.ButtonTextLabeled(
|
||||
"AIImages.Settings.ShotType".Translate(),
|
||||
settings.shotType.ToString()
|
||||
)
|
||||
)
|
||||
{
|
||||
List<FloatMenuOption> shotOptions = new List<FloatMenuOption>();
|
||||
foreach (ShotType shot in Enum.GetValues(typeof(ShotType)))
|
||||
{
|
||||
ShotType localShot = shot;
|
||||
shotOptions.Add(
|
||||
new FloatMenuOption(shot.ToString(), () => settings.shotType = localShot)
|
||||
);
|
||||
}
|
||||
Find.WindowStack.Add(new FloatMenu(shotOptions));
|
||||
}
|
||||
|
||||
DrawArtStyleDropdown(listingStandard, settings);
|
||||
listingStandard.Gap(8f);
|
||||
|
||||
// Steps
|
||||
listingStandard.Label("AIImages.Settings.Steps".Translate() + $": {settings.steps}");
|
||||
settings.steps = (int)listingStandard.Slider(settings.steps, 1, 150);
|
||||
listingStandard.Gap(8f);
|
||||
|
||||
// CFG Scale
|
||||
listingStandard.Label(
|
||||
"AIImages.Settings.CfgScale".Translate() + $": {settings.cfgScale:F1}"
|
||||
);
|
||||
settings.cfgScale = listingStandard.Slider(settings.cfgScale, 1f, 30f);
|
||||
listingStandard.Gap(8f);
|
||||
|
||||
// Width
|
||||
DrawSizeSettings(listingStandard, settings);
|
||||
listingStandard.Gap(12f);
|
||||
}
|
||||
|
||||
private static void DrawArtStyleDropdown(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
// Получаем текущий стиль
|
||||
var currentStyleDef = DefDatabase<ArtStyleDef>.GetNamedSilentFail(
|
||||
settings.artStyleDefName
|
||||
);
|
||||
string currentStyleLabel = currentStyleDef?.label ?? settings.artStyleDefName;
|
||||
|
||||
if (
|
||||
listingStandard.ButtonTextLabeled(
|
||||
"AIImages.Settings.ArtStyle".Translate(),
|
||||
currentStyleLabel
|
||||
)
|
||||
)
|
||||
{
|
||||
List<FloatMenuOption> styleOptions = new List<FloatMenuOption>();
|
||||
|
||||
// Получаем все стили из DefDatabase и сортируем по sortOrder
|
||||
var allStyles = DefDatabase<ArtStyleDef>.AllDefs.OrderBy(s => s.sortOrder);
|
||||
|
||||
foreach (var styleDef in allStyles)
|
||||
{
|
||||
string localDefName = styleDef.defName;
|
||||
string localLabel = styleDef.label;
|
||||
|
||||
styleOptions.Add(
|
||||
new FloatMenuOption(
|
||||
localLabel,
|
||||
() => settings.artStyleDefName = localDefName
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Find.WindowStack.Add(new FloatMenu(styleOptions));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawSizeSettings(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
listingStandard.Label("AIImages.Settings.Width".Translate() + ":");
|
||||
widthBuffer = listingStandard.TextEntry(widthBuffer);
|
||||
if (int.TryParse(widthBuffer, out int width))
|
||||
@@ -129,7 +221,6 @@ namespace AIImages
|
||||
settings.width = Mathf.Clamp(width, 64, 2048);
|
||||
}
|
||||
|
||||
// Height
|
||||
listingStandard.Label("AIImages.Settings.Height".Translate() + ":");
|
||||
heightBuffer = listingStandard.TextEntry(heightBuffer);
|
||||
if (int.TryParse(heightBuffer, out int height))
|
||||
@@ -137,41 +228,132 @@ namespace AIImages
|
||||
settings.height = Mathf.Clamp(height, 64, 2048);
|
||||
}
|
||||
|
||||
// Common size presets
|
||||
listingStandard.Gap(4f);
|
||||
Rect presetRect = listingStandard.GetRect(30f);
|
||||
if (Widgets.ButtonText(new Rect(presetRect.x, presetRect.y, 80f, 30f), "512x512"))
|
||||
{
|
||||
settings.width = 512;
|
||||
settings.height = 512;
|
||||
widthBuffer = "512";
|
||||
heightBuffer = "512";
|
||||
DrawSizePresets(listingStandard, settings);
|
||||
}
|
||||
if (Widgets.ButtonText(new Rect(presetRect.x + 85f, presetRect.y, 80f, 30f), "512x768"))
|
||||
{
|
||||
settings.width = 512;
|
||||
settings.height = 768;
|
||||
widthBuffer = "512";
|
||||
heightBuffer = "768";
|
||||
}
|
||||
if (
|
||||
Widgets.ButtonText(new Rect(presetRect.x + 170f, presetRect.y, 80f, 30f), "768x768")
|
||||
|
||||
private static void DrawSizePresets(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
settings.width = 768;
|
||||
settings.height = 768;
|
||||
widthBuffer = "768";
|
||||
heightBuffer = "768";
|
||||
// Получаем все предустановки размеров из DefDatabase
|
||||
var allPresets = DefDatabase<ImageSizePresetDef>
|
||||
.AllDefs.OrderBy(p => p.sortOrder)
|
||||
.ToList();
|
||||
|
||||
if (!allPresets.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
listingStandard.Gap(12f);
|
||||
listingStandard.Gap(4f);
|
||||
|
||||
// Sampler
|
||||
listingStandard.Label("AIImages.Settings.Sampler".Translate() + ":");
|
||||
settings.selectedSampler = listingStandard.TextEntry(settings.selectedSampler);
|
||||
listingStandard.Gap(12f);
|
||||
// Разбиваем на строки по 3 кнопки
|
||||
int buttonsPerRow = 3;
|
||||
float buttonWidth = 80f;
|
||||
float spacing = 5f;
|
||||
|
||||
// === Prompts ===
|
||||
for (int i = 0; i < allPresets.Count; i += buttonsPerRow)
|
||||
{
|
||||
Rect rowRect = listingStandard.GetRect(30f);
|
||||
|
||||
for (int j = 0; j < buttonsPerRow && (i + j) < allPresets.Count; j++)
|
||||
{
|
||||
var preset = allPresets[i + j];
|
||||
float xOffset = j * (buttonWidth + spacing);
|
||||
|
||||
if (
|
||||
Widgets.ButtonText(
|
||||
new Rect(rowRect.x + xOffset, rowRect.y, buttonWidth, 30f),
|
||||
preset.label
|
||||
)
|
||||
)
|
||||
{
|
||||
settings.width = preset.width;
|
||||
settings.height = preset.height;
|
||||
widthBuffer = preset.width.ToString();
|
||||
heightBuffer = preset.height.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
listingStandard.Gap(4f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawSamplerSchedulerSettings(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
DrawSamplerDropdown(listingStandard, settings);
|
||||
DrawSchedulerDropdown(listingStandard, settings);
|
||||
listingStandard.Gap(12f);
|
||||
}
|
||||
|
||||
private static void DrawSamplerDropdown(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
if (
|
||||
listingStandard.ButtonTextLabeled(
|
||||
"AIImages.Settings.Sampler".Translate(),
|
||||
settings.selectedSampler
|
||||
)
|
||||
)
|
||||
{
|
||||
List<FloatMenuOption> samplerOptions = new List<FloatMenuOption>();
|
||||
var availableSamplers = settings.availableSamplers.Any()
|
||||
? settings.availableSamplers
|
||||
: new List<string> { settings.selectedSampler };
|
||||
|
||||
foreach (string sampler in availableSamplers)
|
||||
{
|
||||
string localSampler = sampler;
|
||||
samplerOptions.Add(
|
||||
new FloatMenuOption(sampler, () => settings.selectedSampler = localSampler)
|
||||
);
|
||||
}
|
||||
Find.WindowStack.Add(new FloatMenu(samplerOptions));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawSchedulerDropdown(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
if (
|
||||
listingStandard.ButtonTextLabeled(
|
||||
"AIImages.Settings.Scheduler".Translate(),
|
||||
settings.selectedScheduler
|
||||
)
|
||||
)
|
||||
{
|
||||
List<FloatMenuOption> schedulerOptions = new List<FloatMenuOption>();
|
||||
var availableSchedulers = settings.availableSchedulers.Any()
|
||||
? settings.availableSchedulers
|
||||
: new List<string> { settings.selectedScheduler };
|
||||
|
||||
foreach (string scheduler in availableSchedulers)
|
||||
{
|
||||
string localScheduler = scheduler;
|
||||
schedulerOptions.Add(
|
||||
new FloatMenuOption(
|
||||
scheduler,
|
||||
() => settings.selectedScheduler = localScheduler
|
||||
)
|
||||
);
|
||||
}
|
||||
Find.WindowStack.Add(new FloatMenu(schedulerOptions));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawPromptsSettings(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
listingStandard.Label(
|
||||
"AIImages.Settings.PromptsSection".Translate(),
|
||||
-1f,
|
||||
@@ -186,8 +368,13 @@ namespace AIImages
|
||||
listingStandard.Label("AIImages.Settings.BaseNegativePrompt".Translate() + ":");
|
||||
settings.baseNegativePrompt = listingStandard.TextEntry(settings.baseNegativePrompt, 3);
|
||||
listingStandard.Gap(12f);
|
||||
}
|
||||
|
||||
// === Options ===
|
||||
private static void DrawOptionsSettings(
|
||||
Listing_Standard listingStandard,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
listingStandard.Label("AIImages.Settings.OptionsSection".Translate());
|
||||
listingStandard.GapLine();
|
||||
|
||||
@@ -203,23 +390,41 @@ namespace AIImages
|
||||
"AIImages.Settings.SaveHistory".Translate(),
|
||||
ref settings.saveGenerationHistory
|
||||
);
|
||||
listingStandard.CheckboxLabeled(
|
||||
"AIImages.Settings.SaveImagesToServer".Translate(),
|
||||
ref settings.saveImagesToServer,
|
||||
"AIImages.Settings.SaveImagesToServerTooltip".Translate()
|
||||
);
|
||||
listingStandard.CheckboxLabeled(
|
||||
"AIImages.Settings.EnableDebugLogs".Translate(),
|
||||
ref settings.enableDebugLogs,
|
||||
"AIImages.Settings.EnableDebugLogsTooltip".Translate()
|
||||
);
|
||||
|
||||
listingStandard.Gap(12f);
|
||||
|
||||
// Save path
|
||||
listingStandard.Label("AIImages.Settings.SavePath".Translate() + ":");
|
||||
settings.savePath = listingStandard.TextEntry(settings.savePath);
|
||||
|
||||
listingStandard.End();
|
||||
Widgets.EndScrollView();
|
||||
listingStandard.Gap(12f);
|
||||
|
||||
// Кнопка очистки всех изображений
|
||||
if (listingStandard.ButtonText("AIImages.Settings.ClearAllImages".Translate()))
|
||||
{
|
||||
ClearAllGeneratedImages(settings);
|
||||
}
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task TestApiConnection(string endpoint)
|
||||
private static void TestApiConnection(
|
||||
IStableDiffusionApiService apiService,
|
||||
string endpoint
|
||||
)
|
||||
{
|
||||
try
|
||||
_ = AsyncHelper.RunAsync(
|
||||
async () =>
|
||||
{
|
||||
Log.Message($"[AI Images] Testing connection to {endpoint}...");
|
||||
bool available = await AIImagesMod.ApiService.CheckApiAvailability(endpoint);
|
||||
bool available = await apiService.CheckApiAvailability(endpoint);
|
||||
|
||||
if (available)
|
||||
{
|
||||
@@ -235,50 +440,106 @@ namespace AIImages
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Messages.Message($"Error: {ex.Message}", MessageTypeDefOf.RejectInput);
|
||||
}
|
||||
},
|
||||
"API Connection Test"
|
||||
);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task LoadModelsFromApi(
|
||||
private static void LoadAllFromApi(
|
||||
IStableDiffusionApiService apiService,
|
||||
AIImagesModSettings settings
|
||||
)
|
||||
{
|
||||
try
|
||||
_ = AsyncHelper.RunAsync(
|
||||
async () =>
|
||||
{
|
||||
Log.Message("[AI Images] Loading models from API...");
|
||||
var models = await AIImagesMod.ApiService.GetAvailableModels(settings.apiEndpoint);
|
||||
Log.Message("[AI Images] Loading models, samplers and schedulers from API...");
|
||||
|
||||
if (models.Count > 0)
|
||||
{
|
||||
Messages.Message(
|
||||
"AIImages.Settings.ModelsLoaded".Translate(models.Count),
|
||||
MessageTypeDefOf.PositiveEvent
|
||||
);
|
||||
// Загружаем модели
|
||||
var models = await apiService.GetAvailableModels(settings.apiEndpoint);
|
||||
settings.availableModels = models;
|
||||
|
||||
// Если модель не выбрана, выбираем первую
|
||||
if (string.IsNullOrEmpty(settings.selectedModel) && models.Count > 0)
|
||||
// Загружаем семплеры
|
||||
var samplers = await apiService.GetAvailableSamplers(settings.apiEndpoint);
|
||||
settings.availableSamplers = samplers;
|
||||
|
||||
// Загружаем schedulers
|
||||
var schedulers = await apiService.GetAvailableSchedulers(settings.apiEndpoint);
|
||||
settings.availableSchedulers = schedulers;
|
||||
|
||||
int totalCount = models.Count + samplers.Count + schedulers.Count;
|
||||
if (totalCount > 0)
|
||||
{
|
||||
settings.selectedModel = models[0];
|
||||
}
|
||||
ShowSuccessMessage(models.Count, samplers.Count, schedulers.Count);
|
||||
AutoSelectDefaults(settings, models, samplers, schedulers);
|
||||
}
|
||||
else
|
||||
{
|
||||
Messages.Message(
|
||||
"AIImages.Settings.NoModelsFound".Translate(),
|
||||
"AIImages.Settings.NothingLoaded".Translate(),
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
}
|
||||
},
|
||||
"Load API Data"
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
private static void ShowSuccessMessage(int modelCount, int samplerCount, int schedulerCount)
|
||||
{
|
||||
Messages.Message(
|
||||
$"Error loading models: {ex.Message}",
|
||||
MessageTypeDefOf.RejectInput
|
||||
"AIImages.Settings.AllLoaded".Translate(modelCount, samplerCount, schedulerCount),
|
||||
MessageTypeDefOf.PositiveEvent
|
||||
);
|
||||
}
|
||||
|
||||
private static void AutoSelectDefaults(
|
||||
AIImagesModSettings settings,
|
||||
List<string> models,
|
||||
List<string> samplers,
|
||||
List<string> schedulers
|
||||
)
|
||||
{
|
||||
AutoSelectIfNeeded(ref settings.selectedModel, models, settings.selectedModel);
|
||||
AutoSelectIfNeeded(ref settings.selectedSampler, samplers, settings.selectedSampler);
|
||||
AutoSelectIfNeeded(
|
||||
ref settings.selectedScheduler,
|
||||
schedulers,
|
||||
settings.selectedScheduler
|
||||
);
|
||||
}
|
||||
|
||||
private static void AutoSelectIfNeeded(
|
||||
ref string selectedValue,
|
||||
List<string> availableValues,
|
||||
string currentValue
|
||||
)
|
||||
{
|
||||
bool needsSelection =
|
||||
string.IsNullOrEmpty(currentValue) || !availableValues.Contains(currentValue);
|
||||
|
||||
if (needsSelection && availableValues.Count > 0)
|
||||
{
|
||||
selectedValue = availableValues[0];
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearAllGeneratedImages(AIImagesModSettings settings)
|
||||
{
|
||||
Find.WindowStack.Add(
|
||||
Dialog_MessageBox.CreateConfirmation(
|
||||
"AIImages.Settings.ClearAllImagesConfirm".Translate(),
|
||||
delegate
|
||||
{
|
||||
int deletedCount = PawnPortraitHelper.ClearAllPortraits(settings.savePath);
|
||||
Messages.Message(
|
||||
"AIImages.Settings.ClearAllImagesSuccess".Translate(deletedCount),
|
||||
MessageTypeDefOf.PositiveEvent
|
||||
);
|
||||
},
|
||||
destructive: true
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
287
Source/AIImages/Validation/SettingsValidator.cs
Normal file
287
Source/AIImages/Validation/SettingsValidator.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AIImages.Settings;
|
||||
|
||||
namespace AIImages.Validation
|
||||
{
|
||||
/// <summary>
|
||||
/// Валидатор для настроек мода AI Images
|
||||
/// </summary>
|
||||
public static class SettingsValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Валидирует все настройки и возвращает список ошибок
|
||||
/// </summary>
|
||||
public static ValidationResult Validate(AIImagesModSettings settings)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
result.AddError("Settings object is null");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Валидация API endpoint
|
||||
ValidateApiEndpoint(settings.apiEndpoint, result);
|
||||
|
||||
// Валидация размеров изображения
|
||||
ValidateImageDimensions(settings.width, settings.height, result);
|
||||
|
||||
// Валидация steps
|
||||
ValidateSteps(settings.steps, result);
|
||||
|
||||
// Валидация CFG scale
|
||||
ValidateCfgScale(settings.cfgScale, result);
|
||||
|
||||
// Валидация sampler и scheduler
|
||||
ValidateSamplerAndScheduler(
|
||||
settings.selectedSampler,
|
||||
settings.selectedScheduler,
|
||||
result
|
||||
);
|
||||
|
||||
// Валидация пути сохранения
|
||||
ValidateSavePath(settings.savePath, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateApiEndpoint(string endpoint, ValidationResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
result.AddError("API endpoint cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri uri))
|
||||
{
|
||||
result.AddError($"Invalid API endpoint format: {endpoint}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
result.AddWarning($"API endpoint should use HTTP or HTTPS protocol: {endpoint}");
|
||||
}
|
||||
|
||||
// Проверка на localhost/127.0.0.1
|
||||
if (
|
||||
uri.Host != "localhost"
|
||||
&& uri.Host != "127.0.0.1"
|
||||
&& !uri.Host.StartsWith("192.168.")
|
||||
)
|
||||
{
|
||||
result.AddWarning(
|
||||
"API endpoint is not pointing to a local address. Make sure the API is accessible."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateImageDimensions(int width, int height, ValidationResult result)
|
||||
{
|
||||
const int minDimension = 64;
|
||||
const int maxDimension = 2048;
|
||||
const int recommendedMin = 512;
|
||||
const int recommendedMax = 1024;
|
||||
|
||||
if (width < minDimension || width > maxDimension)
|
||||
{
|
||||
result.AddError(
|
||||
$"Width must be between {minDimension} and {maxDimension}. Current: {width}"
|
||||
);
|
||||
}
|
||||
|
||||
if (height < minDimension || height > maxDimension)
|
||||
{
|
||||
result.AddError(
|
||||
$"Height must be between {minDimension} and {maxDimension}. Current: {height}"
|
||||
);
|
||||
}
|
||||
|
||||
// Проверка кратности 8 (рекомендация для Stable Diffusion)
|
||||
if (width % 8 != 0)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"Width should be divisible by 8 for optimal results. Current: {width}"
|
||||
);
|
||||
}
|
||||
|
||||
if (height % 8 != 0)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"Height should be divisible by 8 for optimal results. Current: {height}"
|
||||
);
|
||||
}
|
||||
|
||||
// Предупреждения о производительности
|
||||
if (width > recommendedMax || height > recommendedMax)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"Large image dimensions ({width}x{height}) may result in slow generation and high memory usage"
|
||||
);
|
||||
}
|
||||
|
||||
if (width < recommendedMin || height < recommendedMin)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"Small image dimensions ({width}x{height}) may result in lower quality"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSteps(int steps, ValidationResult result)
|
||||
{
|
||||
const int minSteps = 1;
|
||||
const int maxSteps = 150;
|
||||
const int recommendedMin = 20;
|
||||
const int recommendedMax = 50;
|
||||
|
||||
if (steps < minSteps || steps > maxSteps)
|
||||
{
|
||||
result.AddError(
|
||||
$"Steps must be between {minSteps} and {maxSteps}. Current: {steps}"
|
||||
);
|
||||
}
|
||||
|
||||
if (steps < recommendedMin)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"Low steps value ({steps}) may result in lower quality. Recommended: {recommendedMin}-{recommendedMax}"
|
||||
);
|
||||
}
|
||||
|
||||
if (steps > recommendedMax)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"High steps value ({steps}) may result in slow generation with minimal quality improvement"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCfgScale(float cfgScale, ValidationResult result)
|
||||
{
|
||||
const float minCfg = 1.0f;
|
||||
const float maxCfg = 30.0f;
|
||||
const float recommendedMin = 5.0f;
|
||||
const float recommendedMax = 15.0f;
|
||||
|
||||
if (cfgScale < minCfg || cfgScale > maxCfg)
|
||||
{
|
||||
result.AddError(
|
||||
$"CFG Scale must be between {minCfg} and {maxCfg}. Current: {cfgScale}"
|
||||
);
|
||||
}
|
||||
|
||||
if (cfgScale < recommendedMin)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"Low CFG scale ({cfgScale}) may ignore prompt. Recommended: {recommendedMin}-{recommendedMax}"
|
||||
);
|
||||
}
|
||||
|
||||
if (cfgScale > recommendedMax)
|
||||
{
|
||||
result.AddWarning(
|
||||
$"High CFG scale ({cfgScale}) may result in over-saturated or distorted images"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSamplerAndScheduler(
|
||||
string sampler,
|
||||
string scheduler,
|
||||
ValidationResult result
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sampler))
|
||||
{
|
||||
result.AddWarning("Sampler is not selected. A default sampler will be used.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scheduler))
|
||||
{
|
||||
result.AddWarning("Scheduler is not selected. A default scheduler will be used.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSavePath(string savePath, ValidationResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(savePath))
|
||||
{
|
||||
result.AddError("Save path cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка на недопустимые символы в пути
|
||||
char[] invalidChars = System.IO.Path.GetInvalidPathChars();
|
||||
if (savePath.IndexOfAny(invalidChars) >= 0)
|
||||
{
|
||||
result.AddError($"Save path contains invalid characters: {savePath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Результат валидации настроек
|
||||
/// </summary>
|
||||
public class ValidationResult
|
||||
{
|
||||
private readonly List<string> _errors = new List<string>();
|
||||
private readonly List<string> _warnings = new List<string>();
|
||||
|
||||
public bool IsValid => _errors.Count == 0;
|
||||
public bool HasWarnings => _warnings.Count > 0;
|
||||
public IReadOnlyList<string> Errors => _errors;
|
||||
public IReadOnlyList<string> Warnings => _warnings;
|
||||
|
||||
public void AddError(string error)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
_errors.Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddWarning(string warning)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(warning))
|
||||
{
|
||||
_warnings.Add(warning);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetErrorsAsString()
|
||||
{
|
||||
return string.Join("\n", _errors);
|
||||
}
|
||||
|
||||
public string GetWarningsAsString()
|
||||
{
|
||||
return string.Join("\n", _warnings);
|
||||
}
|
||||
|
||||
public string GetAllMessagesAsString()
|
||||
{
|
||||
var messages = new List<string>();
|
||||
|
||||
if (_errors.Count > 0)
|
||||
{
|
||||
messages.Add("Errors:");
|
||||
messages.AddRange(_errors);
|
||||
}
|
||||
|
||||
if (_warnings.Count > 0)
|
||||
{
|
||||
if (messages.Count > 0)
|
||||
messages.Add("");
|
||||
|
||||
messages.Add("Warnings:");
|
||||
messages.AddRange(_warnings);
|
||||
}
|
||||
|
||||
return string.Join("\n", messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
413
Source/AIImages/Window_AIGallery.cs
Normal file
413
Source/AIImages/Window_AIGallery.cs
Normal file
@@ -0,0 +1,413 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AIImages.Helpers;
|
||||
using RimWorld;
|
||||
using UnityEngine;
|
||||
using Verse;
|
||||
|
||||
#pragma warning disable IDE1006 // Naming Styles
|
||||
|
||||
namespace AIImages
|
||||
{
|
||||
/// <summary>
|
||||
/// Окно галереи AI-сгенерированных изображений персонажа
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Style",
|
||||
"IDE1006:Naming Styles",
|
||||
Justification = "RimWorld Window naming convention"
|
||||
)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Minor Code Smell",
|
||||
"S101:Types should be named in PascalCase",
|
||||
Justification = "RimWorld Window naming convention"
|
||||
)]
|
||||
public class Window_AIGallery : Window
|
||||
{
|
||||
private Pawn pawn;
|
||||
private List<string> portraitPaths = new List<string>();
|
||||
private List<Texture2D> portraitTextures = new List<Texture2D>();
|
||||
private Vector2 mainScrollPosition = Vector2.zero;
|
||||
private int selectedIndex = 0;
|
||||
|
||||
public Window_AIGallery(Pawn pawn)
|
||||
{
|
||||
this.pawn = pawn;
|
||||
this.doCloseX = true;
|
||||
this.doCloseButton = true;
|
||||
this.forcePause = false;
|
||||
this.absorbInputAroundWindow = false;
|
||||
this.draggable = true;
|
||||
this.preventCameraMotion = false;
|
||||
|
||||
LoadGallery();
|
||||
}
|
||||
|
||||
public override Vector2 InitialSize => new Vector2(900f, 700f);
|
||||
|
||||
/// <summary>
|
||||
/// Загружает галерею изображений персонажа
|
||||
/// </summary>
|
||||
private void LoadGallery()
|
||||
{
|
||||
DebugLogger.Log($"[AI Gallery] Loading gallery for {pawn?.Name}");
|
||||
|
||||
// Очищаем старые текстуры
|
||||
UnloadTextures();
|
||||
|
||||
// Загружаем пути к портретам
|
||||
portraitPaths = PawnPortraitHelper.GetAllPortraits(pawn);
|
||||
|
||||
// Загружаем текстуры
|
||||
foreach (var path in portraitPaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] imageData = File.ReadAllBytes(path);
|
||||
Texture2D texture = new Texture2D(2, 2);
|
||||
texture.LoadImage(imageData);
|
||||
portraitTextures.Add(texture);
|
||||
DebugLogger.Log($"[AI Gallery] Loaded texture from: {path}");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
DebugLogger.Warning($"[AI Gallery] Failed to load texture: {path}, Error: {ex.Message}");
|
||||
portraitTextures.Add(null);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
DebugLogger.Warning($"[AI Gallery] File not found: {path}");
|
||||
portraitTextures.Add(null);
|
||||
}
|
||||
}
|
||||
|
||||
DebugLogger.Log($"[AI Gallery] Loaded {portraitTextures.Count} textures for {pawn?.Name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Освобождает ресурсы текстур
|
||||
/// </summary>
|
||||
private void UnloadTextures()
|
||||
{
|
||||
foreach (var texture in portraitTextures)
|
||||
{
|
||||
if (texture != null)
|
||||
{
|
||||
Object.Destroy(texture);
|
||||
}
|
||||
}
|
||||
portraitTextures.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Освобождает ресурсы при закрытии окна
|
||||
/// </summary>
|
||||
public override void PreClose()
|
||||
{
|
||||
base.PreClose();
|
||||
UnloadTextures();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отрисовка окна
|
||||
/// </summary>
|
||||
public override void DoWindowContents(Rect inRect)
|
||||
{
|
||||
// Заголовок
|
||||
Text.Font = GameFont.Medium;
|
||||
Widgets.Label(
|
||||
new Rect(0f, 0f, inRect.width, 40f),
|
||||
"AIImages.Gallery.Title".Translate()
|
||||
);
|
||||
|
||||
// Имя персонажа
|
||||
Text.Font = GameFont.Small;
|
||||
Widgets.Label(
|
||||
new Rect(0f, 40f, inRect.width, 30f),
|
||||
"AIImages.Window.PawnLabel".Translate(pawn.NameShortColored.Resolve())
|
||||
);
|
||||
|
||||
// Количество изображений
|
||||
Widgets.Label(
|
||||
new Rect(0f, 70f, inRect.width, 25f),
|
||||
"AIImages.Gallery.Count".Translate(portraitTextures.Count)
|
||||
);
|
||||
|
||||
// Разделитель
|
||||
Widgets.DrawLineHorizontal(0f, 100f, inRect.width);
|
||||
|
||||
// Область для галереи
|
||||
Rect galleryRect = new Rect(0f, 110f, inRect.width, inRect.height - 180f);
|
||||
DrawGallery(galleryRect);
|
||||
|
||||
// Кнопка обновления
|
||||
if (
|
||||
Widgets.ButtonText(
|
||||
new Rect(0f, inRect.height - 70f, inRect.width * 0.3f, 35f),
|
||||
"AIImages.Window.Refresh".Translate()
|
||||
)
|
||||
)
|
||||
{
|
||||
LoadGallery();
|
||||
}
|
||||
|
||||
// Кнопка удаления выбранного изображения
|
||||
if (
|
||||
portraitTextures.Count > 0
|
||||
&& selectedIndex >= 0
|
||||
&& selectedIndex < portraitTextures.Count
|
||||
&& Widgets.ButtonText(
|
||||
new Rect(inRect.width * 0.32f, inRect.height - 70f, inRect.width * 0.3f, 35f),
|
||||
"AIImages.Gallery.DeleteSelected".Translate()
|
||||
)
|
||||
)
|
||||
{
|
||||
DeleteSelectedImage();
|
||||
}
|
||||
|
||||
// Кнопка удаления всех изображений
|
||||
if (
|
||||
portraitTextures.Count > 0
|
||||
&& Widgets.ButtonText(
|
||||
new Rect(inRect.width * 0.64f, inRect.height - 70f, inRect.width * 0.36f, 35f),
|
||||
"AIImages.Gallery.DeleteAll".Translate(portraitTextures.Count)
|
||||
)
|
||||
)
|
||||
{
|
||||
DeleteAllImages();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отрисовка галереи изображений
|
||||
/// </summary>
|
||||
private void DrawGallery(Rect rect)
|
||||
{
|
||||
if (portraitTextures.Count == 0)
|
||||
{
|
||||
Text.Anchor = TextAnchor.MiddleCenter;
|
||||
GUI.color = new Color(1f, 1f, 1f, 0.5f);
|
||||
Widgets.Label(rect, "AIImages.Gallery.Empty".Translate());
|
||||
GUI.color = Color.white;
|
||||
Text.Anchor = TextAnchor.UpperLeft;
|
||||
return;
|
||||
}
|
||||
|
||||
// Параметры сетки
|
||||
int columns = 3;
|
||||
int rows = Mathf.CeilToInt((float)portraitTextures.Count / columns);
|
||||
float cellWidth = (rect.width - 40f) / columns;
|
||||
float cellHeight = cellWidth + 40f; // Высота ячейки (изображение + кнопки)
|
||||
float spacing = 10f;
|
||||
float viewHeight = rows * cellHeight + spacing;
|
||||
|
||||
Rect viewRect = new Rect(0f, 0f, rect.width - 20f, viewHeight);
|
||||
|
||||
Widgets.BeginScrollView(rect, ref mainScrollPosition, viewRect);
|
||||
|
||||
for (int i = 0; i < portraitTextures.Count; i++)
|
||||
{
|
||||
int row = i / columns;
|
||||
int col = i % columns;
|
||||
|
||||
float x = col * (cellWidth + spacing);
|
||||
float y = row * cellHeight;
|
||||
|
||||
Rect cellRect = new Rect(x, y, cellWidth, cellHeight);
|
||||
DrawGalleryItem(cellRect, i);
|
||||
}
|
||||
|
||||
Widgets.EndScrollView();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отрисовка одного элемента галереи
|
||||
/// </summary>
|
||||
private void DrawGalleryItem(Rect rect, int index)
|
||||
{
|
||||
// Подсветка выбранного элемента
|
||||
if (index == selectedIndex)
|
||||
{
|
||||
Widgets.DrawBoxSolid(rect, new Color(0.3f, 0.5f, 0.8f, 0.2f));
|
||||
Widgets.DrawBox(rect, 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
Widgets.DrawBox(rect);
|
||||
}
|
||||
|
||||
// Изображение
|
||||
Texture2D texture = portraitTextures[index];
|
||||
if (texture != null)
|
||||
{
|
||||
Rect imageRect = new Rect(
|
||||
rect.x + 5f,
|
||||
rect.y + 5f,
|
||||
rect.width - 10f,
|
||||
rect.width - 10f
|
||||
);
|
||||
|
||||
// Клик по изображению для выбора
|
||||
if (Widgets.ButtonInvisible(imageRect))
|
||||
{
|
||||
selectedIndex = index;
|
||||
}
|
||||
|
||||
GUI.DrawTexture(imageRect, texture);
|
||||
}
|
||||
else
|
||||
{
|
||||
Rect errorRect = new Rect(rect.x + 5f, rect.y + 5f, rect.width - 10f, rect.width - 10f);
|
||||
Widgets.DrawBoxSolid(errorRect, new Color(0.5f, 0f, 0f, 0.5f));
|
||||
Text.Anchor = TextAnchor.MiddleCenter;
|
||||
GUI.color = Color.white;
|
||||
Widgets.Label(errorRect, "AIImages.Gallery.LoadError".Translate());
|
||||
GUI.color = Color.white;
|
||||
Text.Anchor = TextAnchor.UpperLeft;
|
||||
}
|
||||
|
||||
// Информация под изображением
|
||||
Text.Font = GameFont.Tiny;
|
||||
Text.Anchor = TextAnchor.UpperLeft;
|
||||
float infoY = rect.y + rect.width - 5f;
|
||||
|
||||
// Имя файла
|
||||
string fileName = Path.GetFileName(portraitPaths[index]);
|
||||
if (fileName.Length > 30)
|
||||
{
|
||||
fileName = fileName.Substring(0, 27) + "...";
|
||||
}
|
||||
|
||||
Widgets.Label(
|
||||
new Rect(rect.x + 5f, infoY, rect.width - 10f, 20f),
|
||||
fileName
|
||||
);
|
||||
|
||||
// Дата создания
|
||||
if (File.Exists(portraitPaths[index]))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(portraitPaths[index]);
|
||||
string dateStr = fileInfo.CreationTime.ToString("dd.MM.yyyy HH:mm");
|
||||
Widgets.Label(
|
||||
new Rect(rect.x + 5f, infoY + 15f, rect.width - 10f, 20f),
|
||||
dateStr
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Игнорируем ошибки чтения даты
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет выбранное изображение
|
||||
/// </summary>
|
||||
private void DeleteSelectedImage()
|
||||
{
|
||||
if (selectedIndex < 0 || selectedIndex >= portraitPaths.Count)
|
||||
return;
|
||||
|
||||
string path = portraitPaths[selectedIndex];
|
||||
|
||||
// Подтверждение
|
||||
Find.WindowStack.Add(
|
||||
Dialog_MessageBox.CreateConfirmation(
|
||||
"AIImages.Gallery.ConfirmDelete".Translate(),
|
||||
delegate
|
||||
{
|
||||
// Удаляем из файловой системы
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
DebugLogger.Log($"[AI Gallery] Deleted file: {path}");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Messages.Message(
|
||||
"AIImages.Gallery.DeleteError".Translate(ex.Message),
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем из компонента пешки
|
||||
PawnPortraitHelper.RemovePortrait(pawn, path);
|
||||
|
||||
// Обновляем галерею
|
||||
LoadGallery();
|
||||
|
||||
// Сбрасываем выбор
|
||||
if (selectedIndex >= portraitTextures.Count)
|
||||
{
|
||||
selectedIndex = portraitTextures.Count - 1;
|
||||
}
|
||||
|
||||
Messages.Message(
|
||||
"AIImages.Gallery.Deleted".Translate(),
|
||||
MessageTypeDefOf.PositiveEvent
|
||||
);
|
||||
},
|
||||
destructive: true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет все изображения
|
||||
/// </summary>
|
||||
private void DeleteAllImages()
|
||||
{
|
||||
// Подтверждение
|
||||
Find.WindowStack.Add(
|
||||
Dialog_MessageBox.CreateConfirmation(
|
||||
"AIImages.Gallery.ConfirmDeleteAll".Translate(portraitPaths.Count),
|
||||
delegate
|
||||
{
|
||||
// Удаляем все файлы
|
||||
int deletedCount = 0;
|
||||
foreach (var path in portraitPaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
deletedCount++;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
DebugLogger.Warning(
|
||||
$"[AI Gallery] Failed to delete file: {path}, Error: {ex.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Очищаем компонент пешки
|
||||
PawnPortraitHelper.ClearPortrait(pawn);
|
||||
|
||||
// Обновляем галерею
|
||||
LoadGallery();
|
||||
selectedIndex = 0;
|
||||
|
||||
Messages.Message(
|
||||
"AIImages.Gallery.AllDeleted".Translate(deletedCount),
|
||||
MessageTypeDefOf.PositiveEvent
|
||||
);
|
||||
},
|
||||
destructive: true
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
BIN
Textures/UI/Commands/AIImage.dds
Normal file
BIN
Textures/UI/Commands/AIImage.dds
Normal file
Binary file not shown.
Reference in New Issue
Block a user