novelai-python 0.5.1__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {novelai_python-0.5.1 → novelai_python-0.5.2}/PKG-INFO +23 -4
- {novelai_python-0.5.1 → novelai_python-0.5.2}/README.md +21 -3
- {novelai_python-0.5.1 → novelai_python-0.5.2}/pyproject.toml +2 -1
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate/__init__.py +9 -1
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate/_enum.py +27 -24
- novelai_python-0.5.2/src/novelai_python/tool/image_metadata/__init__.py +297 -0
- novelai_python-0.5.2/src/novelai_python/tool/image_metadata/_type.py +21 -0
- novelai_python-0.5.2/src/novelai_python/tool/image_metadata/bch_utils.py +160 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/image_metadata/lsb_extractor.py +22 -19
- novelai_python-0.5.1/src/novelai_python/tool/image_metadata/__init__.py +0 -225
- {novelai_python-0.5.1 → novelai_python-0.5.2}/LICENSE +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/NOTICE.MD +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_enum.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_exceptions.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/generate.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/generate_image.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/generate_stream.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/generate_voice.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/upscale.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/schema.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/user/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/user/information.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/user/login.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/user/subscription.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/credential/ApiToken.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/credential/JwtToken.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/credential/UserAuth.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/credential/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/credential/_base.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/LICENSE +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/_const.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/_cost.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/_enum.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/augment_image/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/augment_image/_enum.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate/_const.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate/_schema.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate/themes.json +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_image/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_image/suggest_tags.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_stream.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_voice/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_voice/_enum.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/upscale.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/schema.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/user/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/user/information.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/user/login.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/user/subscription.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/server.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/clip_simple_tokenizer.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/nerdstash_v1.model +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/nerdstash_v2.model +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/novelai.model +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/novelai_v2.model +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/image_metadata/lsb_injector.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/paint_mask/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/random_prompt/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/random_prompt/tag.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/random_prompt/tag_character.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/random_prompt/tag_nsfw.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/utils/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/utils/encode.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/utils/useful.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/__init__.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_generate_voice.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_random_prompt.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_server.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_server_run.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_upscale.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_user_information.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_user_login.py +0 -0
- {novelai_python-0.5.1 → novelai_python-0.5.2}/tests/test_user_subscription.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: novelai-python
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: NovelAI Python Binding With Pydantic
|
|
5
5
|
Author-Email: sudoskys <coldlando@hotmail.com>
|
|
6
6
|
License: Apache License 2.0
|
|
@@ -29,6 +29,7 @@ Requires-Dist: regex>=2023.12.25
|
|
|
29
29
|
Requires-Dist: tokenizers>=0.15.2
|
|
30
30
|
Requires-Dist: json-repair>=0.29.4
|
|
31
31
|
Requires-Dist: robust-downloader>=0.0.2
|
|
32
|
+
Requires-Dist: bchlib>=2.1.3
|
|
32
33
|
Provides-Extra: testing
|
|
33
34
|
Requires-Dist: pytest>=7.4.4; extra == "testing"
|
|
34
35
|
Requires-Dist: pytest-asyncio>=0.23.4; extra == "testing"
|
|
@@ -75,7 +76,7 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
|
|
|
75
76
|
### Usage 🖥️
|
|
76
77
|
|
|
77
78
|
```shell
|
|
78
|
-
pip install novelai-python
|
|
79
|
+
pip -U install novelai-python
|
|
79
80
|
```
|
|
80
81
|
|
|
81
82
|
More examples can be found in the [playground](https://github.com/LlmKira/novelai-python/tree/main/playground)
|
|
@@ -122,7 +123,8 @@ from dotenv import load_dotenv
|
|
|
122
123
|
from pydantic import SecretStr
|
|
123
124
|
|
|
124
125
|
from novelai_python import APIError, LoginCredential
|
|
125
|
-
from novelai_python.sdk.ai.generate import TextLLMModel, LLM, get_default_preset
|
|
126
|
+
from novelai_python.sdk.ai.generate import TextLLMModel, LLM, get_default_preset, AdvanceLLMSetting
|
|
127
|
+
from novelai_python.sdk.ai.generate._enum import get_model_preset
|
|
126
128
|
|
|
127
129
|
load_dotenv()
|
|
128
130
|
username = os.getenv("NOVELAI_USER", None)
|
|
@@ -141,8 +143,17 @@ async def chat(prompt: str):
|
|
|
141
143
|
agent = LLM.build(
|
|
142
144
|
prompt=prompt,
|
|
143
145
|
model=model,
|
|
144
|
-
parameters=None # Auto Select or get from preset
|
|
146
|
+
# parameters=None, # Auto Select or get from preset
|
|
147
|
+
parameters=get_model_preset(TextLLMModel.ERATO).get_all_presets()[0].parameters, # Select from enum preset
|
|
148
|
+
advanced_setting=AdvanceLLMSetting(
|
|
149
|
+
min_length=1,
|
|
150
|
+
max_length=None, # Auto
|
|
151
|
+
)
|
|
145
152
|
)
|
|
153
|
+
# NOTE:parameter > advanced_setting, which logic in generate/__init__.py
|
|
154
|
+
# If you not pass the parameter, it will use the default preset.
|
|
155
|
+
# So if you want to set the generation params, you should pass your own params.
|
|
156
|
+
# Only if you want to use some params not affect the generation, you can use advanced_setting.
|
|
146
157
|
result = await agent.request(session=login_credential)
|
|
147
158
|
except APIError as e:
|
|
148
159
|
raise Exception(f"Error: {e.message}")
|
|
@@ -184,6 +195,14 @@ print(tokenizer_package.decode(tokenizer_package.encode(t_text)))
|
|
|
184
195
|
|
|
185
196
|
```
|
|
186
197
|
|
|
198
|
+
## About Nsfw 🚫
|
|
199
|
+
|
|
200
|
+
You might need some solutions for identifying NSFW content and adding a mosaic to prevent operational mishaps.
|
|
201
|
+
|
|
202
|
+
https://dghs-imgutils.deepghs.org/main/api_doc/detect/nudenet.html
|
|
203
|
+
|
|
204
|
+
https://dghs-imgutils.deepghs.org/main/api_doc/operate/censor.html
|
|
205
|
+
|
|
187
206
|
## Acknowledgements 🙏
|
|
188
207
|
|
|
189
208
|
[BackEnd](https://api.novelai.net/docs)
|
|
@@ -39,7 +39,7 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
|
|
|
39
39
|
### Usage 🖥️
|
|
40
40
|
|
|
41
41
|
```shell
|
|
42
|
-
pip install novelai-python
|
|
42
|
+
pip -U install novelai-python
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
More examples can be found in the [playground](https://github.com/LlmKira/novelai-python/tree/main/playground)
|
|
@@ -86,7 +86,8 @@ from dotenv import load_dotenv
|
|
|
86
86
|
from pydantic import SecretStr
|
|
87
87
|
|
|
88
88
|
from novelai_python import APIError, LoginCredential
|
|
89
|
-
from novelai_python.sdk.ai.generate import TextLLMModel, LLM, get_default_preset
|
|
89
|
+
from novelai_python.sdk.ai.generate import TextLLMModel, LLM, get_default_preset, AdvanceLLMSetting
|
|
90
|
+
from novelai_python.sdk.ai.generate._enum import get_model_preset
|
|
90
91
|
|
|
91
92
|
load_dotenv()
|
|
92
93
|
username = os.getenv("NOVELAI_USER", None)
|
|
@@ -105,8 +106,17 @@ async def chat(prompt: str):
|
|
|
105
106
|
agent = LLM.build(
|
|
106
107
|
prompt=prompt,
|
|
107
108
|
model=model,
|
|
108
|
-
parameters=None # Auto Select or get from preset
|
|
109
|
+
# parameters=None, # Auto Select or get from preset
|
|
110
|
+
parameters=get_model_preset(TextLLMModel.ERATO).get_all_presets()[0].parameters, # Select from enum preset
|
|
111
|
+
advanced_setting=AdvanceLLMSetting(
|
|
112
|
+
min_length=1,
|
|
113
|
+
max_length=None, # Auto
|
|
114
|
+
)
|
|
109
115
|
)
|
|
116
|
+
# NOTE:parameter > advanced_setting, which logic in generate/__init__.py
|
|
117
|
+
# If you not pass the parameter, it will use the default preset.
|
|
118
|
+
# So if you want to set the generation params, you should pass your own params.
|
|
119
|
+
# Only if you want to use some params not affect the generation, you can use advanced_setting.
|
|
110
120
|
result = await agent.request(session=login_credential)
|
|
111
121
|
except APIError as e:
|
|
112
122
|
raise Exception(f"Error: {e.message}")
|
|
@@ -148,6 +158,14 @@ print(tokenizer_package.decode(tokenizer_package.encode(t_text)))
|
|
|
148
158
|
|
|
149
159
|
```
|
|
150
160
|
|
|
161
|
+
## About Nsfw 🚫
|
|
162
|
+
|
|
163
|
+
You might need some solutions for identifying NSFW content and adding a mosaic to prevent operational mishaps.
|
|
164
|
+
|
|
165
|
+
https://dghs-imgutils.deepghs.org/main/api_doc/detect/nudenet.html
|
|
166
|
+
|
|
167
|
+
https://dghs-imgutils.deepghs.org/main/api_doc/operate/censor.html
|
|
168
|
+
|
|
151
169
|
## Acknowledgements 🙏
|
|
152
170
|
|
|
153
171
|
[BackEnd](https://api.novelai.net/docs)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "novelai-python"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.2"
|
|
4
4
|
description = "NovelAI Python Binding With Pydantic"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "sudoskys", email = "coldlando@hotmail.com" },
|
|
@@ -28,6 +28,7 @@ dependencies = [
|
|
|
28
28
|
"tokenizers>=0.15.2",
|
|
29
29
|
"json-repair>=0.29.4",
|
|
30
30
|
"robust-downloader>=0.0.2",
|
|
31
|
+
"bchlib>=2.1.3",
|
|
31
32
|
]
|
|
32
33
|
requires-python = ">=3.9"
|
|
33
34
|
readme = "README.md"
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate/__init__.py
RENAMED
|
@@ -305,7 +305,15 @@ class LLM(ApiBaseModel):
|
|
|
305
305
|
**kwargs
|
|
306
306
|
) -> "LLM":
|
|
307
307
|
"""
|
|
308
|
-
Generate
|
|
308
|
+
Generate
|
|
309
|
+
:param prompt: prompt, exp "Hello, World!"
|
|
310
|
+
:param model: TextLLMModelTypeAlias
|
|
311
|
+
:param advanced_setting: AdvanceLLMSetting, default None means auto-select
|
|
312
|
+
:param parameters: LLMGenerationParams, default None means auto-select, use get_model_preset
|
|
313
|
+
:param default_bias: bool, default True
|
|
314
|
+
:param logprobs_count: int, default None
|
|
315
|
+
:param bias_dinkus_asterism: bool, default False
|
|
316
|
+
:param prefix: str, default "vanilla"
|
|
309
317
|
"""
|
|
310
318
|
assert isinstance(prompt, str), "Prompt must be a string"
|
|
311
319
|
created = cls(
|
|
@@ -148,30 +148,12 @@ def get_repetition_penalty_whitelist(group: TextTokenizerGroup) -> List[str]:
|
|
|
148
148
|
return []
|
|
149
149
|
|
|
150
150
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return Genjipython6b.get_all_presets()[0]
|
|
158
|
-
elif model == TextLLMModel.GENJI_JP_6B_V2:
|
|
159
|
-
return Genjijp6bv2.get_all_presets()[0]
|
|
160
|
-
elif model == TextLLMModel.EUTERPE_V2:
|
|
161
|
-
return Euterpev2.get_all_presets()[0]
|
|
162
|
-
elif model == TextLLMModel.KRAKE_V1:
|
|
163
|
-
return Krakev1.get_all_presets()[0]
|
|
164
|
-
elif model == TextLLMModel.KRAKE_V2:
|
|
165
|
-
return Krakev2.get_all_presets()[0]
|
|
166
|
-
elif model in COLORS_LLM:
|
|
167
|
-
return Colorful.get_all_presets()[0]
|
|
168
|
-
elif model == TextLLMModel.CLIO:
|
|
169
|
-
return Clio.get_all_presets()[0]
|
|
170
|
-
elif model == TextLLMModel.KAYRA:
|
|
171
|
-
return Kayra.get_all_presets()[0]
|
|
172
|
-
elif model == TextLLMModel.ERATO:
|
|
173
|
-
return Erato.get_all_presets()[0]
|
|
174
|
-
return Default.get_all_presets()[0]
|
|
151
|
+
def get_model_preset(model: TextLLMModel) -> "ModelPresetManager":
|
|
152
|
+
return PresetMapping.get(model, Default)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_default_preset(model: TextLLMModel) -> Preset:
|
|
156
|
+
return get_model_preset(model).get_all_presets()[0]
|
|
175
157
|
|
|
176
158
|
|
|
177
159
|
class ModelPresetManager:
|
|
@@ -2279,3 +2261,24 @@ Erato.register_preset(
|
|
|
2279
2261
|
description="Designed for writing in Japanese. Works fine for English too."
|
|
2280
2262
|
)
|
|
2281
2263
|
)
|
|
2264
|
+
|
|
2265
|
+
PresetMapping = {
|
|
2266
|
+
TextLLMModel.J_6B_V4: J6bv4,
|
|
2267
|
+
TextLLMModel.NEO_2B: Neo2b,
|
|
2268
|
+
TextLLMModel.GENJI_PYTHON_6B: Genjipython6b,
|
|
2269
|
+
TextLLMModel.GENJI_JP_6B_V2: Genjijp6bv2,
|
|
2270
|
+
TextLLMModel.EUTERPE_V2: Euterpev2,
|
|
2271
|
+
TextLLMModel.KRAKE_V1: Krakev1,
|
|
2272
|
+
TextLLMModel.KRAKE_V2: Krakev2,
|
|
2273
|
+
TextLLMModel.BLUE: Colorful,
|
|
2274
|
+
TextLLMModel.RED: Colorful,
|
|
2275
|
+
TextLLMModel.GREEN: Colorful,
|
|
2276
|
+
TextLLMModel.PURPLE: Colorful,
|
|
2277
|
+
TextLLMModel.PINK: Colorful,
|
|
2278
|
+
TextLLMModel.YELLOW: Colorful,
|
|
2279
|
+
TextLLMModel.WHITE: Colorful,
|
|
2280
|
+
TextLLMModel.BLACK: Colorful,
|
|
2281
|
+
TextLLMModel.CLIO: Clio,
|
|
2282
|
+
TextLLMModel.KAYRA: Kayra,
|
|
2283
|
+
TextLLMModel.ERATO: Erato,
|
|
2284
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from typing import Union, Optional, List, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from PIL import Image
|
|
9
|
+
from PIL.PngImagePlugin import PngInfo
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from nacl.encoding import Base64Encoder
|
|
12
|
+
from nacl.signing import VerifyKey
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from novelai_python.sdk.ai._enum import PROMOTION, Model
|
|
16
|
+
from ._type import IMAGE_INPUT_TYPE, get_image_bytes
|
|
17
|
+
from .bch_utils import fec_decode
|
|
18
|
+
from .lsb_extractor import ImageLsbDataExtractor
|
|
19
|
+
from .lsb_injector import inject_data
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ImageMetadata(BaseModel):
|
|
23
|
+
"""
|
|
24
|
+
ImageMetadata is a class that represents the metadata of a NovelAI generated image.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
class CommentModel(BaseModel):
|
|
28
|
+
prompt: Optional[str] = None
|
|
29
|
+
steps: Optional[int] = None
|
|
30
|
+
height: Optional[int] = None
|
|
31
|
+
width: Optional[int] = None
|
|
32
|
+
scale: Optional[float] = None
|
|
33
|
+
uncond_scale: Optional[float] = None
|
|
34
|
+
cfg_rescale: Optional[float] = None
|
|
35
|
+
seed: Optional[int] = None
|
|
36
|
+
n_samples: Optional[int] = None
|
|
37
|
+
hide_debug_overlay: Optional[bool] = None
|
|
38
|
+
noise_schedule: Optional[str] = None
|
|
39
|
+
legacy_v3_extend: Optional[bool] = None
|
|
40
|
+
reference_information_extracted: Optional[float] = None
|
|
41
|
+
reference_strength: Optional[float] = None
|
|
42
|
+
reference_strength_multiple: Optional[List[float]] = None
|
|
43
|
+
reference_information_extracted_multiple: Optional[List[float]] = None
|
|
44
|
+
sampler: Optional[str] = None
|
|
45
|
+
controlnet_strength: Optional[float] = None
|
|
46
|
+
controlnet_model: Optional[Union[None, str]] = None
|
|
47
|
+
dynamic_thresholding: Optional[bool] = None
|
|
48
|
+
dynamic_thresholding_percentile: Optional[float] = None
|
|
49
|
+
dynamic_thresholding_mimic_scale: Optional[float] = None
|
|
50
|
+
sm: Optional[bool] = None
|
|
51
|
+
sm_dyn: Optional[bool] = None
|
|
52
|
+
skip_cfg_below_sigma: Optional[float] = None
|
|
53
|
+
lora_unet_weights: Optional[Union[None, str]] = None
|
|
54
|
+
lora_clip_weights: Optional[Union[None, str]] = None
|
|
55
|
+
uc: Optional[str] = None
|
|
56
|
+
request_type: Optional[str] = None
|
|
57
|
+
signed_hash: Optional[str] = None
|
|
58
|
+
skip_cfg_above_sigma: Optional[float] = None
|
|
59
|
+
deliberate_euler_ancestral_bug: Optional[bool] = None
|
|
60
|
+
prefer_brownian: Optional[bool] = None
|
|
61
|
+
cfg_sched_eligibility: Optional[str] = None
|
|
62
|
+
explike_fine_detail: Optional[bool] = None
|
|
63
|
+
minimize_sigma_inf: Optional[bool] = None
|
|
64
|
+
uncond_per_vibe: Optional[bool] = None
|
|
65
|
+
wonky_vibe_correlation: Optional[bool] = None
|
|
66
|
+
version: Optional[int] = None
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(extra="allow")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def generate_method(self):
|
|
72
|
+
return self.request_type or "Unknown"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def negative_prompt(self):
|
|
76
|
+
return self.uc
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def vibe_transfer_strength(self) -> List[float]:
|
|
80
|
+
"""
|
|
81
|
+
Get the vibe transfer strength totally
|
|
82
|
+
"""
|
|
83
|
+
if self.reference_strength_multiple:
|
|
84
|
+
return self.reference_strength_multiple
|
|
85
|
+
reference_strength = [] if self.reference_strength is None else [self.reference_strength]
|
|
86
|
+
return reference_strength
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def vibe_transfer_information(self) -> List[float]:
|
|
90
|
+
"""
|
|
91
|
+
Get the vibe transfer information totally
|
|
92
|
+
"""
|
|
93
|
+
if self.reference_information_extracted_multiple:
|
|
94
|
+
return self.reference_information_extracted_multiple
|
|
95
|
+
reference_information = [] if self.reference_information_extracted is None else [
|
|
96
|
+
self.reference_information_extracted]
|
|
97
|
+
return reference_information
|
|
98
|
+
|
|
99
|
+
Title: str = "AI generated image"
|
|
100
|
+
Software: str = "NovelAI"
|
|
101
|
+
Source: str = None
|
|
102
|
+
Description: str
|
|
103
|
+
Comment: CommentModel
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(extra="allow")
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def used_model(self) -> Union[Model, None]:
|
|
109
|
+
"""
|
|
110
|
+
Get the model used to generate the image from the Model code
|
|
111
|
+
:return: Model or None
|
|
112
|
+
"""
|
|
113
|
+
return PROMOTION.get(self.Source, None)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def reset_alpha(image: IMAGE_INPUT_TYPE) -> BytesIO:
|
|
117
|
+
"""
|
|
118
|
+
Remove LSB from the image, set the alpha channel to 254
|
|
119
|
+
:param image: Type: Union[str, bytes, Path, BytesIO]
|
|
120
|
+
:return: BytesIO
|
|
121
|
+
"""
|
|
122
|
+
image_data = get_image_bytes(image)
|
|
123
|
+
image = Image.open(image_data).convert('RGBA')
|
|
124
|
+
data = np.array(image)
|
|
125
|
+
data[..., 3] = 254
|
|
126
|
+
new_image = Image.fromarray(data, 'RGBA')
|
|
127
|
+
_new_img_io = BytesIO()
|
|
128
|
+
new_image.save(_new_img_io, format="PNG")
|
|
129
|
+
return _new_img_io
|
|
130
|
+
|
|
131
|
+
def apply_to_image(self, image: IMAGE_INPUT_TYPE, *, inject_lsb: bool = True) -> BytesIO:
|
|
132
|
+
"""
|
|
133
|
+
Write metadata to origin_image
|
|
134
|
+
If you set inject_lsb to True, the image will be injected with metadata using LSB.
|
|
135
|
+
|
|
136
|
+
**But if you set inject_lsb to False, the image will be reset to the 254 alpha channel**
|
|
137
|
+
:param image: Union[str, bytes, Path, BytesIO]
|
|
138
|
+
:param inject_lsb: Inject metadata using LSB
|
|
139
|
+
:return: BytesIO
|
|
140
|
+
"""
|
|
141
|
+
image_data = get_image_bytes(image)
|
|
142
|
+
origin_image = Image.open(image_data)
|
|
143
|
+
metadata = PngInfo()
|
|
144
|
+
for k, v in self.model_dump(mode="json").items():
|
|
145
|
+
if isinstance(v, dict):
|
|
146
|
+
v = json.dumps(v)
|
|
147
|
+
metadata.add_text(k, v)
|
|
148
|
+
if inject_lsb:
|
|
149
|
+
# Inject metadata using LSB
|
|
150
|
+
origin_image = inject_data(origin_image, metadata)
|
|
151
|
+
|
|
152
|
+
# Prepare image to be saved
|
|
153
|
+
be_copy_image = BytesIO()
|
|
154
|
+
origin_image.save(be_copy_image, format="PNG", pnginfo=metadata, optimize=False, compress_level=0)
|
|
155
|
+
return be_copy_image
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def _extract_metadata_from_lsb(cls, image: IMAGE_INPUT_TYPE):
|
|
159
|
+
"""
|
|
160
|
+
Extract metadata using LSB extraction method.
|
|
161
|
+
:param image: IMAGE_INPUT_TYPE
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
image_meta_data, fec_data = ImageLsbDataExtractor().extract_data(image)
|
|
165
|
+
return cls.model_validate(image_meta_data)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.trace(f"Error extracting data in LSB: {e}")
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def _extract_metadata_from_comments(cls, image: IMAGE_INPUT_TYPE):
|
|
172
|
+
"""
|
|
173
|
+
Extract metadata from image comments and other info.
|
|
174
|
+
:param image: Union[str, bytes, Path, BytesIO]
|
|
175
|
+
"""
|
|
176
|
+
with Image.open(image) as img:
|
|
177
|
+
title = img.info.get("Title", None)
|
|
178
|
+
description = img.info.get("Description", None)
|
|
179
|
+
comment = img.info.get("Comment", "{}")
|
|
180
|
+
try:
|
|
181
|
+
comment_dict = json.loads(comment)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.trace(f"Error loading comment: {e}")
|
|
184
|
+
comment_dict = {}
|
|
185
|
+
try:
|
|
186
|
+
comment_dict.setdefault("prompt", description)
|
|
187
|
+
comment_model = cls.CommentModel.model_validate(comment_dict)
|
|
188
|
+
return cls(Title=title, Description=description, Comment=comment_model)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.debug(f"Error loading comment: {e}")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def load_image(cls, image: IMAGE_INPUT_TYPE):
|
|
195
|
+
"""
|
|
196
|
+
Load image and extract metadata using LSB/Metadata
|
|
197
|
+
:param image: str, bytes, Path, BytesIO
|
|
198
|
+
:return: ImageMetadata
|
|
199
|
+
:raises ValueError: Data extraction failed
|
|
200
|
+
"""
|
|
201
|
+
image_data = get_image_bytes(image)
|
|
202
|
+
metadata = cls._extract_metadata_from_lsb(image_data)
|
|
203
|
+
if metadata:
|
|
204
|
+
return metadata
|
|
205
|
+
metadata = cls._extract_metadata_from_comments(image)
|
|
206
|
+
if metadata:
|
|
207
|
+
return metadata
|
|
208
|
+
raise ValueError("No metadata found")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ImageVerifier:
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def verify_latents(image: Image.Image, signed_hash: bytes, verify_key: VerifyKey):
|
|
215
|
+
image.load()
|
|
216
|
+
sig = None
|
|
217
|
+
latents = None
|
|
218
|
+
try:
|
|
219
|
+
for cid, data, after_idat in image.private_chunks:
|
|
220
|
+
if after_idat:
|
|
221
|
+
if cid == b'ltns':
|
|
222
|
+
sig = data
|
|
223
|
+
elif cid == b'ltnt':
|
|
224
|
+
latents = data
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.trace(f"Error extracting latents: {e}")
|
|
227
|
+
return True, False, None
|
|
228
|
+
if sig is None or latents is None:
|
|
229
|
+
return True, False, None
|
|
230
|
+
if not sig.startswith(b'NovelAI_ltntsig'):
|
|
231
|
+
return False, False, None
|
|
232
|
+
sig = sig[len(b'NovelAI_ltntsig'):]
|
|
233
|
+
if not latents.startswith(b'NovelAI_latents'):
|
|
234
|
+
return False, False, None
|
|
235
|
+
latents = latents[len(b'NovelAI_latents'):]
|
|
236
|
+
if len(sig) != 64:
|
|
237
|
+
return False, False, None
|
|
238
|
+
w, h = image.size
|
|
239
|
+
base_size = (w // 8) * (h // 8) * 4
|
|
240
|
+
if len(latents) != base_size * 4 and len(latents) != base_size * 2:
|
|
241
|
+
return False, False, None
|
|
242
|
+
float_dim = 4 if len(latents) == base_size * 4 else 2
|
|
243
|
+
try:
|
|
244
|
+
verify_key.verify(signed_hash + latents, sig)
|
|
245
|
+
return True, True, (float_dim, latents)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.trace(f"Error verifying latents: {e}")
|
|
248
|
+
return False, False, None
|
|
249
|
+
|
|
250
|
+
def verify(self,
|
|
251
|
+
image,
|
|
252
|
+
verify_key_hex: str = "Y2JcQAOhLwzwSDUJPNgL04nS0Tbqm7cSRc4xk0vRMic="
|
|
253
|
+
) -> Tuple[bool, bool]:
|
|
254
|
+
"""
|
|
255
|
+
Verify if the image is a NovelAI generated image
|
|
256
|
+
|
|
257
|
+
:param image: Union[str, bytes, Path, BytesIO] - The input image to verify.
|
|
258
|
+
:param verify_key_hex: str - The verification key in base64 format.
|
|
259
|
+
:return: Tuple[bool, bool] - The first bool indicates if the image is verified, the second bool indicates if the image has latents.
|
|
260
|
+
:raises ValueError: If the required metadata or signed hash is missing.
|
|
261
|
+
"""
|
|
262
|
+
image_obj = Image.open(get_image_bytes(image))
|
|
263
|
+
w, h = image_obj.size
|
|
264
|
+
image_array = np.array(image_obj)
|
|
265
|
+
try:
|
|
266
|
+
metadata, fec_data = ImageLsbDataExtractor().extract_data(image, get_fec=True)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.trace(f"Error extracting data in LSB: {e}")
|
|
269
|
+
metadata = None
|
|
270
|
+
fec_data = None
|
|
271
|
+
if not metadata or not metadata.get("Comment") or not metadata["Comment"].get("signed_hash"):
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"Bad image lsb or metadata. Comment or Comment.signed_hash is missing, the image is be tampered or not generated by NovelAI."
|
|
274
|
+
)
|
|
275
|
+
parameter = metadata["Comment"]
|
|
276
|
+
signed_hash = base64.b64decode(parameter.pop("signed_hash").encode("utf-8"))
|
|
277
|
+
# Build verify key
|
|
278
|
+
verify_key = VerifyKey(verify_key_hex.encode("utf-8"), encoder=Base64Encoder)
|
|
279
|
+
# Verify latents
|
|
280
|
+
good_latents, have_latents, latents = self.verify_latents(image_obj, signed_hash, verify_key)
|
|
281
|
+
if not good_latents:
|
|
282
|
+
return False, False
|
|
283
|
+
rgb = image_array[:, :, :3].tobytes()
|
|
284
|
+
parameter = json.dumps(parameter).encode("utf-8")
|
|
285
|
+
image_and_comment = rgb + parameter
|
|
286
|
+
try:
|
|
287
|
+
verify_key.verify(image_and_comment, signed_hash)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
logger.trace(f"Error verifying image [1]: {e}")
|
|
290
|
+
try:
|
|
291
|
+
rgb, errs = fec_decode(bytearray(rgb), bytearray(fec_data), w, h)
|
|
292
|
+
image_and_comment = rgb + parameter
|
|
293
|
+
verify_key.verify(image_and_comment, signed_hash)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.trace(f"Error verifying image [2]: {e}")
|
|
296
|
+
return False, False
|
|
297
|
+
return True, have_latents
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
IMAGE_INPUT_TYPE = Union[str, bytes, Path, BytesIO]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_image_bytes(image: IMAGE_INPUT_TYPE) -> BytesIO:
|
|
9
|
+
if isinstance(image, (str, Path)):
|
|
10
|
+
try:
|
|
11
|
+
with open(image, "rb") as f:
|
|
12
|
+
return BytesIO(f.read())
|
|
13
|
+
except FileNotFoundError:
|
|
14
|
+
raise FileNotFoundError(f"Image not found: {image}")
|
|
15
|
+
elif isinstance(image, BytesIO):
|
|
16
|
+
image.seek(0)
|
|
17
|
+
return image
|
|
18
|
+
elif isinstance(image, bytes):
|
|
19
|
+
return BytesIO(image)
|
|
20
|
+
else:
|
|
21
|
+
raise TypeError(f"Invalid image type: {type(image)}, only {IMAGE_INPUT_TYPE} is supported")
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# 纠错码
|
|
2
|
+
# MIT: https://github.com/NovelAI/novelai-image-metadata/blob/main/nai_bch.py
|
|
3
|
+
import functools
|
|
4
|
+
|
|
5
|
+
import bchlib
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
correctable_bits = 16
|
|
9
|
+
block_length = 2019
|
|
10
|
+
code_block_len = 1920
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def bit_shuffle(data_bytes, w, h, use_bytes=False):
|
|
14
|
+
bits = np.frombuffer(data_bytes, dtype=np.uint8)
|
|
15
|
+
bit_fac = 8
|
|
16
|
+
if use_bytes:
|
|
17
|
+
bit_fac = 1
|
|
18
|
+
else:
|
|
19
|
+
bits = np.unpackbits(bits)
|
|
20
|
+
bits = bits.reshape((h, w, 3 * bit_fac))
|
|
21
|
+
code_block_len = 1920
|
|
22
|
+
flat_tile_len = (w * h * 3) // code_block_len
|
|
23
|
+
tile_w = 32
|
|
24
|
+
if flat_tile_len // tile_w > 100:
|
|
25
|
+
tile_w = 64
|
|
26
|
+
tile_h = flat_tile_len // tile_w
|
|
27
|
+
h_cutoff = (h // tile_h) * tile_h
|
|
28
|
+
tile_hr = h - h_cutoff
|
|
29
|
+
easy_tiles = bits[:h_cutoff].reshape(h_cutoff // tile_h, tile_h, w // tile_w, tile_w, 3 * bit_fac)
|
|
30
|
+
easy_tiles = easy_tiles.swapaxes(1, 2)
|
|
31
|
+
easy_tiles = easy_tiles.reshape(-1, tile_h * tile_w)
|
|
32
|
+
easy_tiles = easy_tiles.T
|
|
33
|
+
rest_tiles = bits[h_cutoff:]
|
|
34
|
+
rest_tiles = rest_tiles.reshape(tile_hr, 1, w // tile_w, tile_w, 3 * bit_fac)
|
|
35
|
+
rest_tiles = rest_tiles.swapaxes(1, 2)
|
|
36
|
+
rest_tiles = rest_tiles.reshape(-1, tile_hr * tile_w)
|
|
37
|
+
rest_tiles = rest_tiles.T
|
|
38
|
+
rest_dim = rest_tiles.shape[-1]
|
|
39
|
+
rest_tiles = np.pad(rest_tiles, ((0, 0), (0, easy_tiles.shape[-1] - rest_tiles.shape[-1])), mode='constant',
|
|
40
|
+
constant_values=0)
|
|
41
|
+
bits = np.concatenate((easy_tiles, rest_tiles), axis=0)
|
|
42
|
+
dim = bits.shape[-1]
|
|
43
|
+
bits = bits.reshape((-1,))
|
|
44
|
+
if not use_bytes:
|
|
45
|
+
bits = np.packbits(bits)
|
|
46
|
+
return bytearray(bits.tobytes()), dim, rest_tiles.shape[0], rest_dim
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bit_unshuffle(data_bytes, w, h, dim, rest_size, rest_dim, use_bytes=False):
|
|
50
|
+
bits = np.frombuffer(data_bytes, dtype=np.uint8)
|
|
51
|
+
bit_fac = 8
|
|
52
|
+
if use_bytes:
|
|
53
|
+
bit_fac = 1
|
|
54
|
+
else:
|
|
55
|
+
bits = np.unpackbits(bits)
|
|
56
|
+
code_block_len = 1920
|
|
57
|
+
flat_tile_len = (w * h * 3) // code_block_len
|
|
58
|
+
tile_w = 32
|
|
59
|
+
if flat_tile_len // tile_w > 100:
|
|
60
|
+
tile_w = 64
|
|
61
|
+
tile_h = flat_tile_len // tile_w
|
|
62
|
+
h_cutoff = (h // tile_h) * tile_h
|
|
63
|
+
tile_hr = h - h_cutoff
|
|
64
|
+
bits = bits.reshape((-1, dim))
|
|
65
|
+
rev_cutoff = bits.shape[0] - rest_size
|
|
66
|
+
rest_tiles = bits[rev_cutoff:]
|
|
67
|
+
rest_tiles = rest_tiles.reshape((-1, dim))
|
|
68
|
+
rest_tiles = rest_tiles[:, :rest_dim]
|
|
69
|
+
rest_tiles = rest_tiles.T
|
|
70
|
+
rest_tiles = rest_tiles.reshape((tile_hr, w // tile_w, 1, tile_w, 3 * bit_fac))
|
|
71
|
+
rest_tiles = rest_tiles.swapaxes(1, 2)
|
|
72
|
+
rest_tiles = rest_tiles.reshape((-1,))
|
|
73
|
+
easy_tiles = bits[:rev_cutoff]
|
|
74
|
+
easy_tiles = easy_tiles.T
|
|
75
|
+
easy_tiles = easy_tiles.reshape((h_cutoff // tile_h, w // tile_w, tile_h, tile_w, 3 * bit_fac))
|
|
76
|
+
easy_tiles = easy_tiles.swapaxes(1, 2)
|
|
77
|
+
easy_tiles = easy_tiles.reshape((-1,))
|
|
78
|
+
data_bytes = np.concatenate((easy_tiles, rest_tiles), axis=0)
|
|
79
|
+
if not use_bytes:
|
|
80
|
+
data_bytes = np.packbits(data_bytes)
|
|
81
|
+
data_bytes = data_bytes.tobytes()
|
|
82
|
+
return bytearray(data_bytes)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@functools.lru_cache(maxsize=512)
|
|
86
|
+
def get_indices(len_db):
|
|
87
|
+
indices = np.arange(0, len_db)
|
|
88
|
+
rng = np.random.Generator(np.random.MT19937(31337))
|
|
89
|
+
indices = rng.permutation(indices)
|
|
90
|
+
unshuffled = indices.copy()
|
|
91
|
+
unshuffled[indices] = np.arange(0, len_db)
|
|
92
|
+
return indices, unshuffled
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def rand_byte_shuffle(data_bytes):
|
|
96
|
+
indices, _ = get_indices(len(data_bytes))
|
|
97
|
+
data_bytes = np.frombuffer(data_bytes, dtype=np.uint8)
|
|
98
|
+
data_bytes = bytearray(data_bytes[indices].tobytes())
|
|
99
|
+
return data_bytes
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def rand_byte_unshuffle(data_bytes):
|
|
103
|
+
_, indices = get_indices(len(data_bytes))
|
|
104
|
+
data_bytes = np.frombuffer(data_bytes, dtype=np.uint8)
|
|
105
|
+
data_bytes = bytearray(data_bytes[indices].tobytes())
|
|
106
|
+
return data_bytes
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
use_bytes = True # Bit shuffling provides better resilience, but byte shuffling is much faster and still sufficient
|
|
110
|
+
shuffle_fn = lambda data_bytes, w, h: bit_shuffle(data_bytes, w, h, use_bytes=use_bytes)
|
|
111
|
+
unshuffle_fn = lambda data_bytes, w, h, dim, rest_size, rest_dim: bit_unshuffle(data_bytes, w, h, dim, rest_size,
|
|
112
|
+
rest_dim, use_bytes=use_bytes)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def split_byte_ranges(data_bytes, n, w, h, shuffle=False):
|
|
116
|
+
if shuffle:
|
|
117
|
+
data_bytes, dim, rest_size, rest_dim = shuffle_fn(data_bytes.copy(), w, h)
|
|
118
|
+
chunks = []
|
|
119
|
+
for i in range(0, len(data_bytes), n):
|
|
120
|
+
chunks.append(data_bytes[i:i + n])
|
|
121
|
+
if shuffle:
|
|
122
|
+
return chunks, dim, rest_size, rest_dim
|
|
123
|
+
return chunks
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def pad(data_bytes):
|
|
127
|
+
return bytearray(data_bytes + b'\x00' * (2019 - len(data_bytes)))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Returns codes for the data in data_bytes
|
|
131
|
+
def fec_encode(data_bytes, w, h):
|
|
132
|
+
encoder = bchlib.BCH(16, prim_poly=17475)
|
|
133
|
+
# import galois
|
|
134
|
+
# encoder = galois.BCH(16383, 16383-224, d=17, c=224)
|
|
135
|
+
chunks = [bytearray(encoder.encode(pad(x))) for x in split_byte_ranges(data_bytes, 2019, w, h, shuffle=True)[0]]
|
|
136
|
+
return b''.join(chunks)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Returns the error corrected data and number of corrected errors or None, None if errors are not correctable
|
|
140
|
+
def fec_decode(data_bytes, codes, w, h):
|
|
141
|
+
encoder = bchlib.BCH(16, prim_poly=17475)
|
|
142
|
+
chunks, dim, rest_size, rest_dim = split_byte_ranges(data_bytes, 2019, w, h, shuffle=True)
|
|
143
|
+
codes = split_byte_ranges(codes, 28, w, h)
|
|
144
|
+
corrected = []
|
|
145
|
+
total_errors = 0
|
|
146
|
+
for i, chunk in enumerate(chunks):
|
|
147
|
+
c_len = len(chunk)
|
|
148
|
+
chunk = pad(chunk)
|
|
149
|
+
code = bytearray(codes[i])
|
|
150
|
+
n_err = encoder.decode(chunk, code)
|
|
151
|
+
if n_err > 0:
|
|
152
|
+
total_errors += n_err
|
|
153
|
+
encoder.correct(chunk, code)
|
|
154
|
+
# Ignoring the following case, since it might be caused by corrupted codes which might not even be needed
|
|
155
|
+
# if n_err < 0:
|
|
156
|
+
# raise ValueError("Too many errors to correct")
|
|
157
|
+
corrected.append(chunk[:c_len])
|
|
158
|
+
corrected = b''.join(corrected)
|
|
159
|
+
corrected = unshuffle_fn(corrected, w, h, dim, rest_size, rest_dim)
|
|
160
|
+
return corrected, total_errors
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# MIT: https://github.com/NovelAI/novelai-image-metadata/blob/main/nai_meta.py
|
|
2
|
+
import gzip
|
|
3
|
+
import json
|
|
2
4
|
from io import BytesIO
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import Union
|
|
5
7
|
|
|
6
|
-
from PIL import Image
|
|
7
8
|
import numpy as np
|
|
8
|
-
import
|
|
9
|
-
import json
|
|
9
|
+
from PIL import Image
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class LSBExtractor(object):
|
|
@@ -59,42 +59,45 @@ class ImageLsbDataExtractor(object):
|
|
|
59
59
|
def __init__(self):
|
|
60
60
|
self.magic = "stealth_pngcomp"
|
|
61
61
|
|
|
62
|
-
def extract_data(self, image: Union[
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
def extract_data(self, image: Union[BytesIO, bytes, Path], get_fec: bool = False) -> tuple:
|
|
63
|
+
if isinstance(image, (bytes, BytesIO, Path)):
|
|
64
|
+
img = Image.open(image)
|
|
65
|
+
img.convert("RGBA")
|
|
66
|
+
image = np.array(img)
|
|
67
|
+
else:
|
|
68
|
+
raise TypeError(f"Invalid image type: {type(image)}, only {type(BytesIO)} is supported")
|
|
69
69
|
|
|
70
|
+
try:
|
|
70
71
|
if not (image.shape[-1] == 4 and len(image.shape) == 3):
|
|
71
|
-
raise AssertionError('image format error')
|
|
72
|
-
|
|
72
|
+
raise AssertionError('image format error, maybe image already be modified')
|
|
73
73
|
reader = LSBExtractor(image)
|
|
74
|
-
|
|
75
74
|
read_magic = reader.get_next_n_bytes(len(self.magic)).decode("utf-8")
|
|
76
75
|
if not (self.magic == read_magic):
|
|
77
76
|
raise AssertionError('magic number mismatch')
|
|
78
|
-
|
|
79
77
|
read_len = reader.read_32bit_integer() // 8
|
|
80
78
|
json_data = reader.get_next_n_bytes(read_len)
|
|
81
|
-
|
|
82
79
|
json_data = json.loads(gzip.decompress(json_data).decode("utf-8"))
|
|
83
80
|
if "Comment" in json_data:
|
|
84
81
|
json_data["Comment"] = json.loads(json_data["Comment"])
|
|
85
|
-
|
|
82
|
+
|
|
83
|
+
if not get_fec:
|
|
84
|
+
return json_data, None
|
|
85
|
+
|
|
86
|
+
fec_len = reader.read_32bit_integer()
|
|
87
|
+
fec_data = None
|
|
88
|
+
if fec_len != 0xffffffff:
|
|
89
|
+
fec_data = reader.get_next_n_bytes(fec_len // 8)
|
|
90
|
+
|
|
91
|
+
return json_data, fec_data
|
|
86
92
|
except FileNotFoundError:
|
|
87
93
|
# 无法找到文件
|
|
88
94
|
raise Exception(f"The file {image} does not exist.")
|
|
89
|
-
|
|
90
95
|
except json.JSONDecodeError as e:
|
|
91
96
|
# 无法解析JSON数据
|
|
92
97
|
raise Exception(f"Failed to decode JSON data from image: {image}. Error: {str(e)}")
|
|
93
|
-
|
|
94
98
|
except AssertionError as err:
|
|
95
99
|
# 魔数不匹配
|
|
96
100
|
raise Exception(f"Failed to extract data from image: {image}. Error: {str(err)}")
|
|
97
|
-
|
|
98
101
|
except Exception as e:
|
|
99
102
|
# 从图像中提取数据时发生意外错误
|
|
100
103
|
raise Exception(f"Unexpected error happened when extracting data from image: {image}. Error: {str(e)}")
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
import base64
|
|
3
|
-
import json
|
|
4
|
-
from io import BytesIO
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Union, Optional, List
|
|
7
|
-
|
|
8
|
-
import numpy as np
|
|
9
|
-
from PIL import Image
|
|
10
|
-
from PIL.PngImagePlugin import PngInfo
|
|
11
|
-
from loguru import logger
|
|
12
|
-
from nacl.encoding import Base64Encoder
|
|
13
|
-
from nacl.signing import VerifyKey
|
|
14
|
-
from pydantic import BaseModel, ConfigDict
|
|
15
|
-
|
|
16
|
-
from .lsb_extractor import ImageLsbDataExtractor
|
|
17
|
-
from .lsb_injector import inject_data
|
|
18
|
-
from novelai_python.sdk.ai._enum import PROMOTION, Model
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class ImageMetadata(BaseModel):
|
|
22
|
-
"""
|
|
23
|
-
ImageMetadata is a class that represents the metadata of a NovelAI generated image.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
class CommentModel(BaseModel):
|
|
27
|
-
prompt: str
|
|
28
|
-
steps: int = None
|
|
29
|
-
height: int = None
|
|
30
|
-
width: int = None
|
|
31
|
-
scale: float = None
|
|
32
|
-
uncond_scale: float = None
|
|
33
|
-
cfg_rescale: float = None
|
|
34
|
-
seed: int = None
|
|
35
|
-
n_samples: int = None
|
|
36
|
-
hide_debug_overlay: bool = None
|
|
37
|
-
noise_schedule: str = None
|
|
38
|
-
legacy_v3_extend: bool = None
|
|
39
|
-
reference_information_extracted: Optional[float] = None
|
|
40
|
-
reference_strength: Optional[float] = None
|
|
41
|
-
reference_strength_multiple: Optional[List[float]] = None
|
|
42
|
-
reference_information_extracted_multiple: Optional[List[float]] = None
|
|
43
|
-
sampler: str = None
|
|
44
|
-
controlnet_strength: float = None
|
|
45
|
-
controlnet_model: Union[None, str] = None
|
|
46
|
-
dynamic_thresholding: bool = None
|
|
47
|
-
dynamic_thresholding_percentile: float = None
|
|
48
|
-
dynamic_thresholding_mimic_scale: float = None
|
|
49
|
-
sm: bool = None
|
|
50
|
-
sm_dyn: bool = None
|
|
51
|
-
skip_cfg_below_sigma: float = None
|
|
52
|
-
lora_unet_weights: Union[None, str] = None
|
|
53
|
-
lora_clip_weights: Union[None, str] = None
|
|
54
|
-
uc: str = None
|
|
55
|
-
request_type: str = None
|
|
56
|
-
signed_hash: str = None
|
|
57
|
-
model_config = ConfigDict(extra="allow")
|
|
58
|
-
|
|
59
|
-
@property
|
|
60
|
-
def negative_prompt(self):
|
|
61
|
-
return self.uc
|
|
62
|
-
|
|
63
|
-
@property
|
|
64
|
-
def vibe_transfer_strength(self) -> List[float]:
|
|
65
|
-
"""
|
|
66
|
-
Get the vibe transfer strength totally
|
|
67
|
-
:return: List[float]
|
|
68
|
-
"""
|
|
69
|
-
if self.reference_strength_multiple:
|
|
70
|
-
return self.reference_strength_multiple
|
|
71
|
-
reference_strength = [] if self.reference_strength is None else [self.reference_strength]
|
|
72
|
-
return reference_strength
|
|
73
|
-
|
|
74
|
-
@property
|
|
75
|
-
def vibe_transfer_information(self) -> List[float]:
|
|
76
|
-
"""
|
|
77
|
-
Get the vibe transfer information totally
|
|
78
|
-
:return: List[float]
|
|
79
|
-
"""
|
|
80
|
-
if self.reference_information_extracted_multiple:
|
|
81
|
-
return self.reference_information_extracted_multiple
|
|
82
|
-
reference_information = [] if self.reference_information_extracted is None else [
|
|
83
|
-
self.reference_information_extracted]
|
|
84
|
-
return reference_information
|
|
85
|
-
|
|
86
|
-
Title: str = "AI generated image"
|
|
87
|
-
Software: str = "NovelAI"
|
|
88
|
-
Source: str = None
|
|
89
|
-
Description: str
|
|
90
|
-
Comment: CommentModel
|
|
91
|
-
"""
|
|
92
|
-
AI generated image
|
|
93
|
-
silvery white, best quality, amazing quality, very aesthetic, absurdres
|
|
94
|
-
{'prompt': 'silvery white, best quality, amazing quality, very aesthetic, absurdres', 'steps': 28, 'height': 128, 'width': 128, 'scale': 10.0, 'uncond_scale': 1.0, 'cfg_rescale': 0.38, 'seed': 1148692016, 'n_samples': 1, 'hide_debug_overlay': False, 'noise_schedule': 'native', 'legacy_v3_extend': False, 'reference_information_extracted': 0.87, 'reference_strength': 1.0, 'sampler': 'k_euler', 'controlnet_strength': 1.0, 'controlnet_model': None, 'dynamic_thresholding': False, 'dynamic_thresholding_percentile': 0.999, 'dynamic_thresholding_mimic_scale': 10.0, 'sm': True, 'sm_dyn': True, 'skip_cfg_below_sigma': 0.0, 'lora_unet_weights': None, 'lora_clip_weights': None, 'uc': 'nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract], small', 'request_type': 'PromptGenerateRequest', 'signed_hash': 'GmLfTgQTOI4WRcTloiSmenArH/Y8szJdVJ1DW18yeqYPkqw3o1zellB4xCQFYle+0tuF9KVP8RkHiDNbQXr0DA=='}
|
|
95
|
-
"""
|
|
96
|
-
model_config = ConfigDict(extra="allow")
|
|
97
|
-
|
|
98
|
-
@property
|
|
99
|
-
def used_model(self) -> Union[Model, None]:
|
|
100
|
-
"""
|
|
101
|
-
Get the model used to generate the image from the Model code
|
|
102
|
-
:return: Model or None
|
|
103
|
-
"""
|
|
104
|
-
return PROMOTION.get(self.Source, None)
|
|
105
|
-
|
|
106
|
-
@staticmethod
|
|
107
|
-
def reset_alpha(input_img: BytesIO) -> BytesIO:
|
|
108
|
-
"""
|
|
109
|
-
Remove LSB from the image, set the alpha channel to 254
|
|
110
|
-
:param input_img:
|
|
111
|
-
:return:
|
|
112
|
-
"""
|
|
113
|
-
if isinstance(input_img, BytesIO):
|
|
114
|
-
input_img.seek(0)
|
|
115
|
-
image = Image.open(input_img).convert('RGBA')
|
|
116
|
-
data = np.array(image)
|
|
117
|
-
data[..., 3] = 254
|
|
118
|
-
new_image = Image.fromarray(data, 'RGBA')
|
|
119
|
-
_new_img_io = BytesIO()
|
|
120
|
-
new_image.save(_new_img_io, format="PNG")
|
|
121
|
-
return _new_img_io
|
|
122
|
-
|
|
123
|
-
def apply_to_image(self,
|
|
124
|
-
origin_image: Image.Image,
|
|
125
|
-
*,
|
|
126
|
-
inject_lsb: bool = True
|
|
127
|
-
) -> BytesIO:
|
|
128
|
-
"""
|
|
129
|
-
Write metadata to origin_image
|
|
130
|
-
If you set inject_lsb to True, the image will be injected with metadata using LSB.
|
|
131
|
-
|
|
132
|
-
**But if you set inject_lsb to False, the image will be reset to the 254 alpha channel**
|
|
133
|
-
:param origin_image: BytesIO
|
|
134
|
-
:param inject_lsb: Inject metadata using LSB
|
|
135
|
-
:return: BytesIO
|
|
136
|
-
"""
|
|
137
|
-
metadata = PngInfo()
|
|
138
|
-
for k, v in self.model_dump(mode="json").items():
|
|
139
|
-
if isinstance(v, dict):
|
|
140
|
-
v = json.dumps(v)
|
|
141
|
-
metadata.add_text(k, v)
|
|
142
|
-
if inject_lsb:
|
|
143
|
-
# Inject metadata using LSB
|
|
144
|
-
origin_image = inject_data(origin_image, metadata)
|
|
145
|
-
# Save original image with metadata (and LSB if inject_lsb is True)
|
|
146
|
-
new_img = BytesIO()
|
|
147
|
-
origin_image.save(new_img, format="PNG", pnginfo=metadata, optimize=False, compress_level=0)
|
|
148
|
-
return new_img
|
|
149
|
-
|
|
150
|
-
@classmethod
|
|
151
|
-
def load_image(cls,
|
|
152
|
-
image_io: Union[str, bytes, Path, BytesIO]
|
|
153
|
-
):
|
|
154
|
-
"""
|
|
155
|
-
Load image and extract metadata using LSB/Metadata
|
|
156
|
-
:param image_io: str, bytes, Path, BytesIO
|
|
157
|
-
:return: ImageMetadata
|
|
158
|
-
:raises ValueError: Data extraction failed
|
|
159
|
-
"""
|
|
160
|
-
if isinstance(image_io, BytesIO):
|
|
161
|
-
image_io.seek(0)
|
|
162
|
-
try:
|
|
163
|
-
image_data = ImageLsbDataExtractor().extract_data(image_io)
|
|
164
|
-
model = cls.model_validate(image_data)
|
|
165
|
-
except Exception as e:
|
|
166
|
-
logger.trace(f"Error trying extracting data in LSB: {e}")
|
|
167
|
-
else:
|
|
168
|
-
return model
|
|
169
|
-
with Image.open(image_io) as img:
|
|
170
|
-
title = img.info.get("Title", None)
|
|
171
|
-
prompt = img.info.get("Description", None)
|
|
172
|
-
comment = img.info.get("Comment", None)
|
|
173
|
-
try:
|
|
174
|
-
assert isinstance(comment, str), ValueError("Comment Empty")
|
|
175
|
-
comment = json.loads(comment)
|
|
176
|
-
except Exception as e:
|
|
177
|
-
logger.trace(f"Error loading comment: {e}")
|
|
178
|
-
comment = {}
|
|
179
|
-
if comment.get("prompt", None) is None:
|
|
180
|
-
comment["prompt"] = prompt
|
|
181
|
-
try:
|
|
182
|
-
comment_model = cls.CommentModel.model_validate(comment)
|
|
183
|
-
return cls(Title=title, Description=prompt, Comment=comment_model)
|
|
184
|
-
except Exception as e:
|
|
185
|
-
logger.debug(f"Error loading comment: {e}")
|
|
186
|
-
raise ValueError("Data extraction failed")
|
|
187
|
-
|
|
188
|
-
@staticmethod
|
|
189
|
-
def verify_image_is_novelai(
|
|
190
|
-
image: Union[Image.Image, np.ndarray],
|
|
191
|
-
verify_key_hex: str = "Y2JcQAOhLwzwSDUJPNgL04nS0Tbqm7cSRc4xk0vRMic="
|
|
192
|
-
) -> bool:
|
|
193
|
-
"""
|
|
194
|
-
Verify if the image is a NovelAI generated image
|
|
195
|
-
:param image: Image.Image or np.ndarray
|
|
196
|
-
:param verify_key_hex:
|
|
197
|
-
:return: bool
|
|
198
|
-
:raises RuntimeError: No metadata found in image
|
|
199
|
-
:raises RuntimeError: Comment not in metadata
|
|
200
|
-
:raises RuntimeError: signed_hash not in comment
|
|
201
|
-
"""
|
|
202
|
-
# MIT:https://github.com/NovelAI/novelai-image-metadata/blob/main/nai_sig.py
|
|
203
|
-
if isinstance(image, Image.Image):
|
|
204
|
-
image = np.array(image)
|
|
205
|
-
metadata = ImageLsbDataExtractor().extract_data(image)
|
|
206
|
-
|
|
207
|
-
if metadata is None:
|
|
208
|
-
raise RuntimeError("No metadata found in image")
|
|
209
|
-
if "Comment" not in metadata:
|
|
210
|
-
raise RuntimeError("Comment not in metadata")
|
|
211
|
-
comment = metadata["Comment"]
|
|
212
|
-
if "signed_hash" not in comment:
|
|
213
|
-
raise RuntimeError("signed_hash not in comment")
|
|
214
|
-
signed_hash = comment["signed_hash"].encode("utf-8")
|
|
215
|
-
signed_hash = base64.b64decode(signed_hash)
|
|
216
|
-
del comment["signed_hash"]
|
|
217
|
-
verify_key_hex = verify_key_hex.encode("utf-8")
|
|
218
|
-
verify_key = VerifyKey(verify_key_hex, encoder=Base64Encoder)
|
|
219
|
-
image_and_comment = image[:, :, :3].tobytes() + json.dumps(comment).encode("utf-8")
|
|
220
|
-
try:
|
|
221
|
-
verify_key.verify(image_and_comment, signed_hash)
|
|
222
|
-
except Exception as e:
|
|
223
|
-
logger.trace(e)
|
|
224
|
-
return False
|
|
225
|
-
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/generate_image.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/generate_stream.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/ai/generate_voice.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/user/information.py
RENAMED
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/_response/user/subscription.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/augment_image/__init__.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/augment_image/_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate/themes.json
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_image/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_voice/__init__.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/sdk/ai/generate_voice/_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/clip_simple_tokenizer.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/nerdstash_v1.model
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tokenizer/nerdstash_v2.model
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/image_metadata/lsb_injector.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/paint_mask/__init__.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/random_prompt/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/random_prompt/tag_character.py
RENAMED
|
File without changes
|
{novelai_python-0.5.1 → novelai_python-0.5.2}/src/novelai_python/tool/random_prompt/tag_nsfw.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|