novelai-python 0.2.8__tar.gz → 0.3.0__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.2.8 → novelai_python-0.3.0}/PKG-INFO +8 -3
- {novelai_python-0.2.8 → novelai_python-0.3.0}/README.md +6 -2
- {novelai_python-0.2.8 → novelai_python-0.3.0}/pyproject.toml +2 -1
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/__init__.py +4 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/ai/generate_image.py +3 -1
- novelai_python-0.3.0/src/novelai_python/_response/ai/upscale.py +19 -0
- novelai_python-0.3.0/src/novelai_python/_response/schema.py +11 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/user/information.py +4 -2
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/user/login.py +3 -2
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/user/subscription.py +3 -1
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/credential/JwtToken.py +1 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/credential/UserAuth.py +1 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/__init__.py +1 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/ai/generate_image.py +29 -12
- novelai_python-0.3.0/src/novelai_python/sdk/ai/upscale.py +204 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/user/login.py +1 -1
- novelai_python-0.3.0/src/novelai_python/utils/useful.py +104 -0
- novelai_python-0.3.0/tests/test_upscale.py +78 -0
- novelai_python-0.2.8/src/novelai_python/utils/useful.py +0 -46
- {novelai_python-0.2.8 → novelai_python-0.3.0}/LICENSE +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/NOTICE.MD +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_exceptions.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/ai/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/user/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/credential/ApiToken.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/credential/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/credential/_base.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/ai/LICENSE +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/ai/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/schema.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/user/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/user/information.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/sdk/user/subscription.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/server.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/encode.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/hash.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/random_prompt/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/random_prompt/tag.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/random_prompt/tag_artist.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/random_prompt/tag_character.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/random_prompt/tag_nsfw.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/tests/__init__.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/tests/test_random_prompt.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/tests/test_server.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/tests/test_server_run.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/tests/test_user_information.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/tests/test_user_login.py +0 -0
- {novelai_python-0.2.8 → novelai_python-0.3.0}/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.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Novelai Python Binding With Pydantic
|
|
5
5
|
Author-Email: sudoskys <coldlando@hotmail.com>
|
|
6
6
|
License: Apache License 2.0
|
|
@@ -19,6 +19,7 @@ Requires-Dist: fastapi>=0.109.0
|
|
|
19
19
|
Requires-Dist: uvicorn[standard]>=0.27.0.post1
|
|
20
20
|
Requires-Dist: numpy>=1.24.4
|
|
21
21
|
Requires-Dist: argon2-cffi>=23.1.0
|
|
22
|
+
Requires-Dist: opencv-python>=4.8.1.78
|
|
22
23
|
Requires-Dist: pytest>=7.4.4; extra == "testing"
|
|
23
24
|
Requires-Dist: pytest-asyncio>=0.23.4; extra == "testing"
|
|
24
25
|
Provides-Extra: testing
|
|
@@ -26,7 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
26
27
|
|
|
27
28
|
# novelai-python
|
|
28
29
|
|
|
29
|
-
[](https://badge.fury.io/py/novelai-python)
|
|
30
31
|
[](https://pepy.tech/project/novelai_python)
|
|
31
32
|
|
|
32
33
|
The goal of this repository is to use Pydantic to build legitimate requests to access the Novelai API service.
|
|
@@ -37,16 +38,20 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
|
|
|
37
38
|
- [x] /user/subscription
|
|
38
39
|
- [x] /user/login
|
|
39
40
|
- [x] /user/information
|
|
41
|
+
- [x] /ai/upscale
|
|
40
42
|
- [ ] /ai/generate-image/suggest-tags
|
|
41
43
|
- [ ] /ai/annotate-image
|
|
42
44
|
- [ ] /ai/classify
|
|
43
|
-
- [ ] /ai/upscale
|
|
44
45
|
- [ ] /ai/generate-prompt
|
|
45
46
|
- [ ] /ai/generate
|
|
46
47
|
- [ ] /ai/generate-voice
|
|
47
48
|
|
|
48
49
|
### Usage 🖥️
|
|
49
50
|
|
|
51
|
+
```shell
|
|
52
|
+
pip install novelai-python
|
|
53
|
+
```
|
|
54
|
+
|
|
50
55
|
More examples can be found in the [playground](https://github.com/LlmKira/novelai-python/tree/main/playground) directory.
|
|
51
56
|
|
|
52
57
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# novelai-python
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/py/novelai-python)
|
|
4
4
|
[](https://pepy.tech/project/novelai_python)
|
|
5
5
|
|
|
6
6
|
The goal of this repository is to use Pydantic to build legitimate requests to access the Novelai API service.
|
|
@@ -11,16 +11,20 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
|
|
|
11
11
|
- [x] /user/subscription
|
|
12
12
|
- [x] /user/login
|
|
13
13
|
- [x] /user/information
|
|
14
|
+
- [x] /ai/upscale
|
|
14
15
|
- [ ] /ai/generate-image/suggest-tags
|
|
15
16
|
- [ ] /ai/annotate-image
|
|
16
17
|
- [ ] /ai/classify
|
|
17
|
-
- [ ] /ai/upscale
|
|
18
18
|
- [ ] /ai/generate-prompt
|
|
19
19
|
- [ ] /ai/generate
|
|
20
20
|
- [ ] /ai/generate-voice
|
|
21
21
|
|
|
22
22
|
### Usage 🖥️
|
|
23
23
|
|
|
24
|
+
```shell
|
|
25
|
+
pip install novelai-python
|
|
26
|
+
```
|
|
27
|
+
|
|
24
28
|
More examples can be found in the [playground](https://github.com/LlmKira/novelai-python/tree/main/playground) directory.
|
|
25
29
|
|
|
26
30
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "novelai-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Novelai Python Binding With Pydantic"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "sudoskys", email = "coldlando@hotmail.com" },
|
|
@@ -18,6 +18,7 @@ dependencies = [
|
|
|
18
18
|
"uvicorn[standard]>=0.27.0.post1",
|
|
19
19
|
"numpy>=1.24.4",
|
|
20
20
|
"argon2-cffi>=23.1.0",
|
|
21
|
+
"opencv-python>=4.8.1.78",
|
|
21
22
|
]
|
|
22
23
|
requires-python = ">=3.8"
|
|
23
24
|
readme = "README.md"
|
|
@@ -16,11 +16,15 @@ from .sdk import GenerateImageInfer, ImageGenerateResp
|
|
|
16
16
|
from .sdk import Information, InformationResp
|
|
17
17
|
from .sdk import Login, LoginResp
|
|
18
18
|
from .sdk import Subscription, SubscriptionResp
|
|
19
|
+
from .sdk import Upscale, UpscaleResp
|
|
19
20
|
|
|
20
21
|
__all__ = [
|
|
21
22
|
"GenerateImageInfer",
|
|
22
23
|
"ImageGenerateResp",
|
|
23
24
|
|
|
25
|
+
"Upscale",
|
|
26
|
+
"UpscaleResp",
|
|
27
|
+
|
|
24
28
|
"Subscription",
|
|
25
29
|
"SubscriptionResp",
|
|
26
30
|
|
{novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/ai/generate_image.py
RENAMED
|
@@ -7,8 +7,10 @@ from typing import Tuple, List
|
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
|
+
from ..schema import RespBase
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
|
|
13
|
+
class ImageGenerateResp(RespBase):
|
|
12
14
|
class RequestParams(BaseModel):
|
|
13
15
|
endpoint: str
|
|
14
16
|
raw_request: dict = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# @Time : 2024/2/13 上午11:29
|
|
3
|
+
# @Author : sudoskys
|
|
4
|
+
# @File : upscale.py
|
|
5
|
+
# @Software: PyCharm
|
|
6
|
+
from typing import Tuple
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from ..schema import RespBase
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UpscaleResp(RespBase):
|
|
14
|
+
class RequestParams(BaseModel):
|
|
15
|
+
endpoint: str
|
|
16
|
+
raw_request: dict = None
|
|
17
|
+
|
|
18
|
+
meta: RequestParams
|
|
19
|
+
files: Tuple[str, bytes] = None
|
{novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/user/information.py
RENAMED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
# @File : information.py
|
|
5
5
|
# @Software: PyCharm
|
|
6
6
|
|
|
7
|
-
from pydantic import
|
|
7
|
+
from pydantic import Field
|
|
8
8
|
|
|
9
|
+
from ..schema import RespBase
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
|
|
12
|
+
class InformationResp(RespBase):
|
|
11
13
|
emailVerified: bool = Field(..., description="Email verification status")
|
|
12
14
|
emailVerificationLetterSent: bool = Field(..., description="Email verification letter sent status")
|
|
13
15
|
trialActivated: bool = Field(..., description="Trial activation status")
|
{novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/_response/user/subscription.py
RENAMED
|
@@ -7,6 +7,8 @@ from typing import Optional, Dict, Any, List
|
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel, Field
|
|
9
9
|
|
|
10
|
+
from ..schema import RespBase
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
class TrainingSteps(BaseModel):
|
|
12
14
|
fixedTrainingStepsLeft: int
|
|
@@ -30,7 +32,7 @@ class Perks(BaseModel):
|
|
|
30
32
|
contextTokens: int
|
|
31
33
|
|
|
32
34
|
|
|
33
|
-
class SubscriptionResp(
|
|
35
|
+
class SubscriptionResp(RespBase):
|
|
34
36
|
tier: int = Field(..., description="Subscription tier")
|
|
35
37
|
active: bool = Field(..., description="Subscription status")
|
|
36
38
|
expiresAt: int = Field(..., description="Subscription expiration time")
|
|
@@ -24,6 +24,7 @@ class JwtCredential(CredentialBase):
|
|
|
24
24
|
self._session = AsyncSession(timeout=timeout, headers={
|
|
25
25
|
"Accept": "*/*",
|
|
26
26
|
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
|
|
27
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
|
27
28
|
"Accept-Encoding": "gzip, deflate, br",
|
|
28
29
|
"Authorization": f"Bearer {self.jwt_token.get_secret_value()}",
|
|
29
30
|
"Content-Type": "application/json",
|
|
@@ -30,6 +30,7 @@ class LoginCredential(CredentialBase):
|
|
|
30
30
|
resp = await Login.build(user_name=self.username, password=self.password.get_secret_value()).request()
|
|
31
31
|
self._session = AsyncSession(timeout=timeout, headers={
|
|
32
32
|
"Accept": "*/*",
|
|
33
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
|
33
34
|
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
|
|
34
35
|
"Accept-Encoding": "gzip, deflate, br",
|
|
35
36
|
"Authorization": f"Bearer {resp.accessToken}",
|
|
@@ -8,3 +8,4 @@ from .ai.generate_image import GenerateImageInfer, ImageGenerateResp # noqa 401
|
|
|
8
8
|
from .user.information import Information, InformationResp # noqa 401
|
|
9
9
|
from .user.login import Login, LoginResp # noqa 401
|
|
10
10
|
from .user.subscription import Subscription, SubscriptionResp # noqa 401
|
|
11
|
+
from .ai.upscale import Upscale, UpscaleResp # noqa 401
|
|
@@ -22,7 +22,7 @@ from typing_extensions import override
|
|
|
22
22
|
|
|
23
23
|
from ..schema import ApiBaseModel
|
|
24
24
|
from ..._exceptions import APIError, AuthError, ConcurrentGenerationError, SessionHttpError
|
|
25
|
-
from ..._response import ImageGenerateResp
|
|
25
|
+
from ..._response.ai.generate_image import ImageGenerateResp
|
|
26
26
|
from ...credential import CredentialBase
|
|
27
27
|
from ...utils import try_jsonfy, NovelAiMetadata
|
|
28
28
|
|
|
@@ -102,7 +102,6 @@ class Resolution(Enum):
|
|
|
102
102
|
|
|
103
103
|
class GenerateImageInfer(ApiBaseModel):
|
|
104
104
|
_endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
|
|
105
|
-
_charge: bool = PrivateAttr(False)
|
|
106
105
|
|
|
107
106
|
class Params(BaseModel):
|
|
108
107
|
# Inpaint
|
|
@@ -208,6 +207,14 @@ class GenerateImageInfer(ApiBaseModel):
|
|
|
208
207
|
parameters: Params = Params()
|
|
209
208
|
model_config = ConfigDict(extra="ignore")
|
|
210
209
|
|
|
210
|
+
@property
|
|
211
|
+
def endpoint(self):
|
|
212
|
+
return self._endpoint
|
|
213
|
+
|
|
214
|
+
@endpoint.setter
|
|
215
|
+
def endpoint(self, value):
|
|
216
|
+
self._endpoint = value
|
|
217
|
+
|
|
211
218
|
@override
|
|
212
219
|
def model_post_init(self, *args) -> None:
|
|
213
220
|
"""
|
|
@@ -242,20 +249,14 @@ class GenerateImageInfer(ApiBaseModel):
|
|
|
242
249
|
def validate_model(self):
|
|
243
250
|
if self.action == Action.INFILL and not self.parameters.mask:
|
|
244
251
|
logger.warning("Mask maybe required for infill mode.")
|
|
252
|
+
if self.action == Action.INFILL:
|
|
253
|
+
self.parameters.extra_noise_seed = self.parameters.seed
|
|
245
254
|
return self
|
|
246
255
|
|
|
247
256
|
@property
|
|
248
257
|
def base_url(self):
|
|
249
258
|
return f"{self.endpoint.strip('/')}/ai/generate-image"
|
|
250
259
|
|
|
251
|
-
@property
|
|
252
|
-
def endpoint(self):
|
|
253
|
-
return self._endpoint
|
|
254
|
-
|
|
255
|
-
@endpoint.setter
|
|
256
|
-
def endpoint(self, value):
|
|
257
|
-
self._endpoint = value
|
|
258
|
-
|
|
259
260
|
def calculate_cost(self, is_opus: bool = False):
|
|
260
261
|
"""
|
|
261
262
|
Calculate the Anlas cost of current parameters.
|
|
@@ -412,14 +413,30 @@ class GenerateImageInfer(ApiBaseModel):
|
|
|
412
413
|
request_data = self.model_dump(mode="json", exclude_none=True)
|
|
413
414
|
# Header
|
|
414
415
|
if isinstance(session, AsyncSession):
|
|
415
|
-
session.headers.update(self.necessary_headers(request_data))
|
|
416
|
+
session.headers.update(await self.necessary_headers(request_data))
|
|
416
417
|
elif isinstance(session, CredentialBase):
|
|
417
418
|
update_header = await self.necessary_headers(request_data)
|
|
418
419
|
session = await session.get_session(update_headers=update_header)
|
|
419
420
|
if override_headers:
|
|
420
421
|
session.headers.clear()
|
|
421
422
|
session.headers.update(override_headers)
|
|
422
|
-
|
|
423
|
+
try:
|
|
424
|
+
_log_data = request_data.copy()
|
|
425
|
+
if self.action == Action.GENERATE:
|
|
426
|
+
logger.debug(f"Request Data: {_log_data}")
|
|
427
|
+
else:
|
|
428
|
+
_log_data.get("parameters", {}).update({
|
|
429
|
+
"image": "base64 data" if self.parameters.image else "None",
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
_log_data.get("parameters", {}).update(
|
|
433
|
+
{
|
|
434
|
+
"mask": "base64 data" if self.parameters.mask else "None",
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
logger.debug(f"Request Data: {request_data}")
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.warning(f"Error when print log data: {e}")
|
|
423
440
|
try:
|
|
424
441
|
assert hasattr(session, "post"), "session must have post method."
|
|
425
442
|
response = await session.post(
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# @Time : 2024/2/13 上午10:48
|
|
3
|
+
# @Author : sudoskys
|
|
4
|
+
# @File : upscale.py
|
|
5
|
+
# @Software: PyCharm
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
from io import BytesIO
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
from zipfile import ZipFile
|
|
12
|
+
|
|
13
|
+
import curl_cffi
|
|
14
|
+
import httpx
|
|
15
|
+
from curl_cffi.requests import AsyncSession
|
|
16
|
+
from loguru import logger
|
|
17
|
+
from pydantic import ConfigDict, PrivateAttr, model_validator
|
|
18
|
+
|
|
19
|
+
from ..schema import ApiBaseModel
|
|
20
|
+
from ..._exceptions import APIError, AuthError, SessionHttpError
|
|
21
|
+
from ..._response.ai.upscale import UpscaleResp
|
|
22
|
+
from ...credential import CredentialBase
|
|
23
|
+
from ...utils import try_jsonfy, NovelAiMetadata
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Upscale(ApiBaseModel):
|
|
27
|
+
_endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
|
|
28
|
+
image: Union[str, bytes] # base64
|
|
29
|
+
width: Optional[int] = None
|
|
30
|
+
height: Optional[int] = None
|
|
31
|
+
scale: float = 4
|
|
32
|
+
model_config = ConfigDict(extra="ignore")
|
|
33
|
+
|
|
34
|
+
@model_validator(mode="after")
|
|
35
|
+
def validate_model(self):
|
|
36
|
+
if isinstance(self.image, str) and self.image.startswith("data:image/"):
|
|
37
|
+
raise ValueError("Invalid image format, must be base64 encoded.")
|
|
38
|
+
if isinstance(self.image, bytes):
|
|
39
|
+
self.image = base64.b64encode(self.image).decode("utf-8")
|
|
40
|
+
# Auto detect image size
|
|
41
|
+
try:
|
|
42
|
+
from PIL import Image
|
|
43
|
+
with Image.open(BytesIO(base64.b64decode(self.image))) as img:
|
|
44
|
+
width, height = img.size
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.warning(f"Error when validate image size: {e}")
|
|
47
|
+
if self.width is None or self.height is None:
|
|
48
|
+
raise ValueError("Invalid image size and cant auto detect, must be set width and height.")
|
|
49
|
+
else:
|
|
50
|
+
if self.width is None:
|
|
51
|
+
self.width = width
|
|
52
|
+
if self.height is None:
|
|
53
|
+
self.height = height
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def base_url(self):
|
|
58
|
+
return f"{self.endpoint.strip('/')}/ai/upscale"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def endpoint(self):
|
|
62
|
+
return self._endpoint
|
|
63
|
+
|
|
64
|
+
@endpoint.setter
|
|
65
|
+
def endpoint(self, value):
|
|
66
|
+
self._endpoint = value
|
|
67
|
+
|
|
68
|
+
async def necessary_headers(self, request_data) -> dict:
|
|
69
|
+
"""
|
|
70
|
+
:param request_data:
|
|
71
|
+
:return:
|
|
72
|
+
"""
|
|
73
|
+
return {
|
|
74
|
+
"Host": urlparse(self.endpoint).netloc,
|
|
75
|
+
"Accept": "*/*",
|
|
76
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
|
77
|
+
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
|
|
78
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
79
|
+
"Referer": "https://novelai.net/",
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
"Origin": "https://novelai.net",
|
|
82
|
+
"Content-Length": str(len(json.dumps(request_data).encode("utf-8"))),
|
|
83
|
+
"Connection": "keep-alive",
|
|
84
|
+
"Sec-Fetch-Dest": "empty",
|
|
85
|
+
"Sec-Fetch-Mode": "cors",
|
|
86
|
+
"Sec-Fetch-Site": "same-site",
|
|
87
|
+
"Pragma": "no-cache",
|
|
88
|
+
"Cache-Control": "no-cache",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async def request(self,
|
|
92
|
+
session: Union[AsyncSession, "CredentialBase"],
|
|
93
|
+
*,
|
|
94
|
+
override_headers: Optional[dict] = None,
|
|
95
|
+
remove_sign: bool = False
|
|
96
|
+
) -> UpscaleResp:
|
|
97
|
+
"""
|
|
98
|
+
生成图片
|
|
99
|
+
:param override_headers:
|
|
100
|
+
:param session: session
|
|
101
|
+
:param remove_sign: 移除追踪信息
|
|
102
|
+
:return:
|
|
103
|
+
"""
|
|
104
|
+
# Data Build
|
|
105
|
+
request_data = self.model_dump(mode="json", exclude_none=True)
|
|
106
|
+
# Header
|
|
107
|
+
if isinstance(session, AsyncSession):
|
|
108
|
+
session.headers.update(await self.necessary_headers(request_data))
|
|
109
|
+
elif isinstance(session, CredentialBase):
|
|
110
|
+
update_header = await self.necessary_headers(request_data)
|
|
111
|
+
session = await session.get_session(update_headers=update_header)
|
|
112
|
+
if override_headers:
|
|
113
|
+
session.headers.clear()
|
|
114
|
+
session.headers.update(override_headers)
|
|
115
|
+
try:
|
|
116
|
+
_log_data = request_data.copy()
|
|
117
|
+
_log_data.update({"image": "base64 data"}) if isinstance(_log_data.get("image"), str) else None
|
|
118
|
+
logger.info(f"Upscale request data: {_log_data}")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Error when print log data: {e}")
|
|
121
|
+
try:
|
|
122
|
+
assert hasattr(session, "post"), "session must have post method."
|
|
123
|
+
response = await session.post(
|
|
124
|
+
self.base_url,
|
|
125
|
+
data=json.dumps(request_data).encode("utf-8")
|
|
126
|
+
)
|
|
127
|
+
if response.headers.get('Content-Type') not in ['binary/octet-stream', 'application/x-zip-compressed']:
|
|
128
|
+
logger.error(
|
|
129
|
+
f"Error with content type: {response.headers.get('Content-Type')} and code: {response.status_code}"
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
_msg = response.json()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.warning(e)
|
|
135
|
+
if not isinstance(response.content, str) and len(response.content) < 50:
|
|
136
|
+
raise APIError(
|
|
137
|
+
message=f"Unexpected content type: {response.headers.get('Content-Type')}",
|
|
138
|
+
request=request_data,
|
|
139
|
+
code=response.status_code,
|
|
140
|
+
response=try_jsonfy(response.content)
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
_msg = {"statusCode": response.status_code, "message": response.content}
|
|
144
|
+
status_code = _msg.get("statusCode", response.status_code)
|
|
145
|
+
message = _msg.get("message", "Unknown error")
|
|
146
|
+
if status_code in [400, 401, 402]:
|
|
147
|
+
# 400 : validation error
|
|
148
|
+
# 401 : unauthorized
|
|
149
|
+
# 402 : payment required
|
|
150
|
+
# 409 : conflict
|
|
151
|
+
raise AuthError(message, request=request_data, code=status_code, response=_msg)
|
|
152
|
+
if status_code in [409]:
|
|
153
|
+
# conflict error
|
|
154
|
+
raise APIError(message, request=request_data, code=status_code, response=_msg)
|
|
155
|
+
"""
|
|
156
|
+
if status_code in [429]:
|
|
157
|
+
# concurrent error
|
|
158
|
+
raise ConcurrentGenerationError(
|
|
159
|
+
message=message,
|
|
160
|
+
request=request_data,
|
|
161
|
+
code=status_code,
|
|
162
|
+
response=_msg
|
|
163
|
+
)
|
|
164
|
+
"""
|
|
165
|
+
raise APIError(message, request=request_data, code=status_code, response=_msg)
|
|
166
|
+
zip_file = ZipFile(BytesIO(response.content))
|
|
167
|
+
unzip_content = []
|
|
168
|
+
with zip_file as zf:
|
|
169
|
+
file_list = zf.namelist()
|
|
170
|
+
if not file_list:
|
|
171
|
+
raise APIError(
|
|
172
|
+
message="No file in zip",
|
|
173
|
+
request=request_data,
|
|
174
|
+
code=response.status_code,
|
|
175
|
+
response=try_jsonfy(response.content)
|
|
176
|
+
)
|
|
177
|
+
for filename in file_list:
|
|
178
|
+
data = zip_file.read(filename)
|
|
179
|
+
if remove_sign:
|
|
180
|
+
try:
|
|
181
|
+
data = NovelAiMetadata.rehash(BytesIO(data), remove_stealth=True)
|
|
182
|
+
if not isinstance(data, bytes):
|
|
183
|
+
data = data.getvalue()
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.exception(f"SdkWarn:Remove sign error: {e}")
|
|
186
|
+
unzip_content.append((filename, data))
|
|
187
|
+
return UpscaleResp(
|
|
188
|
+
meta=UpscaleResp.RequestParams(
|
|
189
|
+
endpoint=self.base_url,
|
|
190
|
+
raw_request=request_data,
|
|
191
|
+
),
|
|
192
|
+
files=unzip_content[0]
|
|
193
|
+
)
|
|
194
|
+
except curl_cffi.requests.errors.RequestsError as exc:
|
|
195
|
+
logger.exception(exc)
|
|
196
|
+
raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!")
|
|
197
|
+
except httpx.HTTPError as exc:
|
|
198
|
+
logger.exception(exc)
|
|
199
|
+
raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!")
|
|
200
|
+
except APIError as e:
|
|
201
|
+
raise e
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.opt(exception=e).exception("An Unexpected error occurred")
|
|
204
|
+
raise e
|
|
@@ -77,7 +77,7 @@ class Login(ApiBaseModel):
|
|
|
77
77
|
# Data Build
|
|
78
78
|
request_data = self.model_dump(mode="json", exclude_none=True)
|
|
79
79
|
if isinstance(session, AsyncSession):
|
|
80
|
-
session.headers.update(self.necessary_headers(request_data))
|
|
80
|
+
session.headers.update(await self.necessary_headers(request_data))
|
|
81
81
|
elif isinstance(session, CredentialBase):
|
|
82
82
|
session = await session.get_session(update_headers=await self.necessary_headers(request_data))
|
|
83
83
|
# Header
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# @Time : 2024/2/9 下午10:41
|
|
3
|
+
# @Author : sudoskys
|
|
4
|
+
# @File : useful.py
|
|
5
|
+
# @Software: PyCharm
|
|
6
|
+
import collections
|
|
7
|
+
import random
|
|
8
|
+
from typing import List, Union
|
|
9
|
+
|
|
10
|
+
import cv2 as cv
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def enum_to_list(enum_):
|
|
15
|
+
return list(map(lambda x: x.value, enum_._member_map_.values()))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueSelect(object):
|
|
19
|
+
def __init__(self, data: List[str]):
|
|
20
|
+
"""
|
|
21
|
+
A queue selector
|
|
22
|
+
:param data:
|
|
23
|
+
"""
|
|
24
|
+
self.data = collections.deque(data)
|
|
25
|
+
self.used = collections.deque()
|
|
26
|
+
self.users = {}
|
|
27
|
+
|
|
28
|
+
def get(self, user_id: Union[int, str]) -> str:
|
|
29
|
+
user_id = str(user_id)
|
|
30
|
+
if user_id not in self.users:
|
|
31
|
+
self.users[user_id] = {'data': self.data.copy(), 'used': collections.deque()}
|
|
32
|
+
|
|
33
|
+
user_data = self.users[user_id]['data']
|
|
34
|
+
user_used = self.users[user_id]['used']
|
|
35
|
+
|
|
36
|
+
if len(user_data) == 0:
|
|
37
|
+
user_data, user_used = user_used, user_data
|
|
38
|
+
# 随机掉 User data
|
|
39
|
+
if len(user_data) > 2:
|
|
40
|
+
random.shuffle(user_data)
|
|
41
|
+
self.users[user_id]['data'] = user_data
|
|
42
|
+
self.users[user_id]['used'] = user_used
|
|
43
|
+
|
|
44
|
+
selected = user_data.popleft()
|
|
45
|
+
user_used.append(selected)
|
|
46
|
+
|
|
47
|
+
return selected
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_mask_from_sketch(original_img_bytes: bytes,
|
|
51
|
+
sketch_img_bytes: bytes,
|
|
52
|
+
output_format: str = '.png',
|
|
53
|
+
jagged_edges: bool = True
|
|
54
|
+
) -> bytes:
|
|
55
|
+
"""
|
|
56
|
+
Function to create a mask from original and sketch images input as bytes. Returns bytes.
|
|
57
|
+
|
|
58
|
+
:param jagged_edges: Whether to create jagged edges in the mask. Defaults to False.
|
|
59
|
+
:param original_img_bytes: Bytes corresponding to the original image.
|
|
60
|
+
:param sketch_img_bytes: Bytes corresponding to the sketch image.
|
|
61
|
+
:param output_format: Format of the output image. Defaults to '.png'. It could also be '.jpg'
|
|
62
|
+
:returns bytes: Bytes corresponding to the resultant mask
|
|
63
|
+
"""
|
|
64
|
+
# Load images
|
|
65
|
+
ori_img = cv.imdecode(np.frombuffer(original_img_bytes, np.uint8), cv.IMREAD_COLOR)
|
|
66
|
+
sketch_img = cv.imdecode(np.frombuffer(sketch_img_bytes, np.uint8), cv.IMREAD_COLOR)
|
|
67
|
+
|
|
68
|
+
# Check if images have the same size
|
|
69
|
+
if ori_img.shape != sketch_img.shape:
|
|
70
|
+
raise ValueError("Images must have the same size.")
|
|
71
|
+
|
|
72
|
+
# Calculate difference between the original and sketch images
|
|
73
|
+
diff_img = cv.absdiff(ori_img, sketch_img)
|
|
74
|
+
|
|
75
|
+
# Convert the difference image to grayscale
|
|
76
|
+
diff_gray = cv.cvtColor(diff_img, cv.COLOR_BGR2GRAY)
|
|
77
|
+
|
|
78
|
+
# Threshold to create the mask
|
|
79
|
+
_, thresh = cv.threshold(diff_gray, 10, 255, cv.THRESH_BINARY)
|
|
80
|
+
|
|
81
|
+
if jagged_edges:
|
|
82
|
+
# Use a bigger kernel for dilation to create larger 'step' effect at the edges
|
|
83
|
+
kernel = np.ones((7, 7), np.uint8)
|
|
84
|
+
else:
|
|
85
|
+
kernel = np.ones((3, 3), np.uint8)
|
|
86
|
+
|
|
87
|
+
# Perform morphological opening to remove small noise
|
|
88
|
+
opening = cv.morphologyEx(thresh, cv.MORPH_OPEN, kernel, iterations=2)
|
|
89
|
+
|
|
90
|
+
# Further remove noise with a Gaussian filter
|
|
91
|
+
smooth = cv.GaussianBlur(opening, (5, 5), 0)
|
|
92
|
+
|
|
93
|
+
if jagged_edges:
|
|
94
|
+
# Apply additional thresholding to create sharper, jagged edges
|
|
95
|
+
_, smooth = cv.threshold(smooth, 128, 255, cv.THRESH_BINARY)
|
|
96
|
+
|
|
97
|
+
# Convert image to BytesIO object
|
|
98
|
+
is_success, buffer = cv.imencode(output_format, smooth)
|
|
99
|
+
if is_success:
|
|
100
|
+
output_io = buffer.tobytes()
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError("Error during conversion of image to BytesIO object")
|
|
103
|
+
|
|
104
|
+
return output_io
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# @Time : 2024/2/13 下午12:03
|
|
3
|
+
# @Author : sudoskys
|
|
4
|
+
# @File : test_upscale.py
|
|
5
|
+
# @Software: PyCharm
|
|
6
|
+
# -*- coding: utf-8 -*-
|
|
7
|
+
# @Time : 2024/2/14 下午4:20
|
|
8
|
+
# @Author : sudoskys
|
|
9
|
+
# @File : test_upscale.py
|
|
10
|
+
# @Software: PyCharm
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from curl_cffi.requests import AsyncSession
|
|
15
|
+
|
|
16
|
+
from novelai_python import APIError, Upscale, AuthError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_validation_error_during_upscale():
|
|
21
|
+
validation_error_response = mock.Mock()
|
|
22
|
+
validation_error_response.headers = {"Content-Type": "application/json"}
|
|
23
|
+
validation_error_response.status_code = 400
|
|
24
|
+
validation_error_response.json.return_value = {
|
|
25
|
+
"statusCode": 400,
|
|
26
|
+
"message": "A validation error occurred."
|
|
27
|
+
}
|
|
28
|
+
session = mock.MagicMock(spec=AsyncSession)
|
|
29
|
+
session.post = mock.AsyncMock(return_value=validation_error_response)
|
|
30
|
+
session.headers = {}
|
|
31
|
+
|
|
32
|
+
upscale = Upscale(image="base64_encoded_image", height=100, width=100)
|
|
33
|
+
with pytest.raises(AuthError) as e:
|
|
34
|
+
await upscale.request(session=session)
|
|
35
|
+
assert e.type is AuthError
|
|
36
|
+
expected_message = 'A validation error occurred.'
|
|
37
|
+
assert expected_message == str(e.value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_unauthorized_error_during_upscale():
|
|
42
|
+
unauthorized_error_response = mock.Mock()
|
|
43
|
+
unauthorized_error_response.headers = {"Content-Type": "application/json"}
|
|
44
|
+
unauthorized_error_response.status_code = 401
|
|
45
|
+
unauthorized_error_response.json.return_value = {
|
|
46
|
+
"statusCode": 401,
|
|
47
|
+
"message": "Unauthorized."
|
|
48
|
+
}
|
|
49
|
+
session = mock.MagicMock(spec=AsyncSession)
|
|
50
|
+
session.post = mock.AsyncMock(return_value=unauthorized_error_response)
|
|
51
|
+
session.headers = {}
|
|
52
|
+
|
|
53
|
+
upscale = Upscale(image="base64_encoded_image", height=100, width=100)
|
|
54
|
+
with pytest.raises(APIError) as e:
|
|
55
|
+
await upscale.request(session=session)
|
|
56
|
+
assert e.type is AuthError
|
|
57
|
+
expected_message = 'Unauthorized.'
|
|
58
|
+
assert expected_message == str(e.value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_unknown_error_during_upscale():
|
|
63
|
+
unknown_error_response = mock.Mock()
|
|
64
|
+
unknown_error_response.headers = {"Content-Type": "application/json"}
|
|
65
|
+
unknown_error_response.status_code = 500
|
|
66
|
+
unknown_error_response.json.return_value = {
|
|
67
|
+
"statusCode": 500,
|
|
68
|
+
"message": "Unknown error occurred."
|
|
69
|
+
}
|
|
70
|
+
session = mock.MagicMock(spec=AsyncSession)
|
|
71
|
+
session.post = mock.AsyncMock(return_value=unknown_error_response)
|
|
72
|
+
session.headers = {}
|
|
73
|
+
|
|
74
|
+
upscale = Upscale(image="base64_encoded_image", height=100, width=100)
|
|
75
|
+
with pytest.raises(APIError) as e:
|
|
76
|
+
await upscale.request(session=session)
|
|
77
|
+
expected_message = 'Unknown error occurred.'
|
|
78
|
+
assert expected_message == str(e.value)
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
# @Time : 2024/2/9 下午10:41
|
|
3
|
-
# @Author : sudoskys
|
|
4
|
-
# @File : useful.py
|
|
5
|
-
# @Software: PyCharm
|
|
6
|
-
import collections
|
|
7
|
-
import random
|
|
8
|
-
from typing import List
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def enum_to_list(enum_):
|
|
12
|
-
return list(map(lambda x: x.value, enum_._member_map_.values()))
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class QueSelect(object):
|
|
16
|
-
def __init__(self, data: List[str]):
|
|
17
|
-
"""
|
|
18
|
-
A queue selector
|
|
19
|
-
:param data:
|
|
20
|
-
"""
|
|
21
|
-
self.data = collections.deque(data)
|
|
22
|
-
self.used = collections.deque()
|
|
23
|
-
self.users = {}
|
|
24
|
-
|
|
25
|
-
def get(self, user_id: int) -> str:
|
|
26
|
-
if user_id not in self.users:
|
|
27
|
-
self.users[user_id] = {'data': self.data.copy(), 'used': collections.deque()}
|
|
28
|
-
|
|
29
|
-
user_data = self.users[user_id]['data']
|
|
30
|
-
user_used = self.users[user_id]['used']
|
|
31
|
-
|
|
32
|
-
if len(user_data) == 0:
|
|
33
|
-
user_data, user_used = user_used, user_data
|
|
34
|
-
# 随机掉 User data
|
|
35
|
-
if len(user_data) > 2:
|
|
36
|
-
random.shuffle(user_data)
|
|
37
|
-
self.users[user_id]['data'] = user_data
|
|
38
|
-
self.users[user_id]['used'] = user_used
|
|
39
|
-
|
|
40
|
-
selected = user_data.popleft()
|
|
41
|
-
user_used.append(selected)
|
|
42
|
-
|
|
43
|
-
return selected
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/random_prompt/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/random_prompt/tag_artist.py
RENAMED
|
File without changes
|
|
File without changes
|
{novelai_python-0.2.8 → novelai_python-0.3.0}/src/novelai_python/utils/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
|