Tu Nguyen commited on
Commit ·
9be3b9f
1
Parent(s): c31d80d
refactoring ok
Browse files- app/api/routes.py +6 -6
- app/application/services/orchestration/process_image.py +3 -10
- app/application/services/orchestration/process_remote_image.py +2 -2
- app/application/services/orchestration/process_upload_image.py +2 -2
- app/application/services/orchestration/source_cleanup.py +2 -1
- app/application/services/outputs/derivative_generator.py +5 -2
- app/application/services/outputs/manifest_writer.py +1 -1
- app/application/services/setup/job_workspace.py +1 -1
- app/application/services/setup/request_options.py +3 -9
- app/domain/constants.py +6 -0
- app/domain/ports/__init__.py +1 -0
- app/domain/ports/media.py +44 -0
- app/domain/{interfaces.py → ports/pipeline.py} +2 -63
- app/domain/ports/sources.py +21 -0
- app/domain/{models.py → schemas.py} +2 -68
- app/domain/types.py +71 -0
- app/infrastructure/composition/dependencies.py +2 -1
- app/infrastructure/config/config_loader.py +2 -1
- app/infrastructure/media/encoder.py +1 -1
- app/infrastructure/media/enhancer.py +1 -1
- app/infrastructure/media/metadata_service.py +1 -1
- app/infrastructure/media/watermark.py +1 -1
- app/infrastructure/sources/remote_source_fetcher.py +1 -1
- app/infrastructure/sources/upload_source_stager.py +1 -1
- app/infrastructure/storage/r2_uploader.py +1 -1
- tests/demo.jpg +3 -0
- tests/integration/api/support.py +2 -1
- tests/integration/api/test_process_upload.py +31 -0
- tests/unit/application/services/orchestration/test_process_image.py +2 -8
- tests/unit/application/services/orchestration/test_process_sources.py +2 -1
- tests/unit/application/services/outputs/test_derivative_generator.py +2 -1
- tests/unit/application/services/outputs/test_manifest_writer.py +2 -4
- tests/unit/application/services/setup/test_request_options.py +1 -1
- tests/unit/infrastructure/media/test_metadata_service.py +1 -1
- tests/unit/infrastructure/media/test_watermark.py +1 -1
app/api/routes.py
CHANGED
|
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
| 7 |
from pydantic import ValidationError
|
| 8 |
|
| 9 |
from app.api.dependencies import get_container
|
| 10 |
-
from app.domain.
|
| 11 |
from app.infrastructure.composition.dependencies import ServiceContainer
|
| 12 |
|
| 13 |
router = APIRouter(tags=["Image Pipeline"])
|
|
@@ -21,16 +21,16 @@ PROCESS_RESPONSE_EXAMPLE = {
|
|
| 21 |
"processed": True,
|
| 22 |
},
|
| 23 |
"metadata": {
|
| 24 |
-
"title": "
|
| 25 |
-
"subject": "
|
| 26 |
-
"tags": ["Goldmark City", "chung
|
| 27 |
-
"comments": "
|
| 28 |
"rating": 5,
|
| 29 |
"gpsLatitude": 21.042953,
|
| 30 |
"gpsLongitude": 105.767916,
|
| 31 |
"artist": "https://www.goldmark-city.com/",
|
| 32 |
"copyright": "https://www.goldmark-city.com/",
|
| 33 |
-
"imageDescription": "
|
| 34 |
"extraTags": {},
|
| 35 |
},
|
| 36 |
"watermark": {
|
|
|
|
| 7 |
from pydantic import ValidationError
|
| 8 |
|
| 9 |
from app.api.dependencies import get_container
|
| 10 |
+
from app.domain.schemas import ProcessRequestModel
|
| 11 |
from app.infrastructure.composition.dependencies import ServiceContainer
|
| 12 |
|
| 13 |
router = APIRouter(tags=["Image Pipeline"])
|
|
|
|
| 21 |
"processed": True,
|
| 22 |
},
|
| 23 |
"metadata": {
|
| 24 |
+
"title": "Căn hộ Goldmark City",
|
| 25 |
+
"subject": "Hình ảnh căn hộ Goldmark City",
|
| 26 |
+
"tags": ["Goldmark City", "chung cư Goldmark"],
|
| 27 |
+
"comments": "Ảnh được xử lý bởi image pipeline",
|
| 28 |
"rating": 5,
|
| 29 |
"gpsLatitude": 21.042953,
|
| 30 |
"gpsLongitude": 105.767916,
|
| 31 |
"artist": "https://www.goldmark-city.com/",
|
| 32 |
"copyright": "https://www.goldmark-city.com/",
|
| 33 |
+
"imageDescription": "Hình ảnh căn hộ Goldmark City",
|
| 34 |
"extraTags": {},
|
| 35 |
},
|
| 36 |
"watermark": {
|
app/application/services/orchestration/process_image.py
CHANGED
|
@@ -3,16 +3,9 @@ from __future__ import annotations
|
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
-
from app.domain.
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
JobWorkspaceFactory,
|
| 10 |
-
ManifestWriter,
|
| 11 |
-
MetadataPort,
|
| 12 |
-
ProcessingOptionsResolver,
|
| 13 |
-
WatermarkAssetResolver,
|
| 14 |
-
)
|
| 15 |
-
from app.domain.models import PipelineDefaultsModel, ProcessRequestModel
|
| 16 |
|
| 17 |
|
| 18 |
class ProcessImageUseCase:
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
+
from app.domain.ports.media import Enhancer, MetadataPort, WatermarkAssetResolver
|
| 7 |
+
from app.domain.ports.pipeline import DerivativeGenerator, JobWorkspaceFactory, ManifestWriter, ProcessingOptionsResolver
|
| 8 |
+
from app.domain.schemas import PipelineDefaultsModel, ProcessRequestModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class ProcessImageUseCase:
|
app/application/services/orchestration/process_remote_image.py
CHANGED
|
@@ -4,8 +4,8 @@ from typing import Any
|
|
| 4 |
|
| 5 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 6 |
from app.application.services.orchestration.source_cleanup import process_fetched_source
|
| 7 |
-
from app.domain.
|
| 8 |
-
from app.domain.
|
| 9 |
|
| 10 |
|
| 11 |
class ProcessRemoteImageUseCase:
|
|
|
|
| 4 |
|
| 5 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 6 |
from app.application.services.orchestration.source_cleanup import process_fetched_source
|
| 7 |
+
from app.domain.ports.sources import RemoteSourceFetcher
|
| 8 |
+
from app.domain.schemas import ProcessRequestModel
|
| 9 |
|
| 10 |
|
| 11 |
class ProcessRemoteImageUseCase:
|
app/application/services/orchestration/process_upload_image.py
CHANGED
|
@@ -4,8 +4,8 @@ from typing import Any
|
|
| 4 |
|
| 5 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 6 |
from app.application.services.orchestration.source_cleanup import process_fetched_source
|
| 7 |
-
from app.domain.
|
| 8 |
-
from app.domain.
|
| 9 |
|
| 10 |
|
| 11 |
class ProcessUploadedImageUseCase:
|
|
|
|
| 4 |
|
| 5 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 6 |
from app.application.services.orchestration.source_cleanup import process_fetched_source
|
| 7 |
+
from app.domain.ports.sources import UploadSourceStager
|
| 8 |
+
from app.domain.schemas import ProcessRequestModel
|
| 9 |
|
| 10 |
|
| 11 |
class ProcessUploadedImageUseCase:
|
app/application/services/orchestration/source_cleanup.py
CHANGED
|
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 6 |
-
from app.domain.
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
def process_fetched_source(
|
|
|
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 6 |
+
from app.domain.schemas import ProcessRequestModel
|
| 7 |
+
from app.domain.types import FetchedSource
|
| 8 |
|
| 9 |
|
| 10 |
def process_fetched_source(
|
app/application/services/outputs/derivative_generator.py
CHANGED
|
@@ -2,8 +2,11 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
from app.domain.
|
| 6 |
-
from app.domain.
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class DefaultDerivativeGenerator:
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
from app.domain.constants import OutputFormat
|
| 6 |
+
from app.domain.ports.media import DerivativeEncoder, MetadataPort
|
| 7 |
+
from app.domain.ports.sources import AssetUploader
|
| 8 |
+
from app.domain.schemas import R2ConfigModel
|
| 9 |
+
from app.domain.types import GeneratedDerivatives, JobWorkspace, OutputArtifact, ResolvedProcessRequest
|
| 10 |
|
| 11 |
|
| 12 |
class DefaultDerivativeGenerator:
|
app/application/services/outputs/manifest_writer.py
CHANGED
|
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
-
from app.domain.
|
| 9 |
|
| 10 |
|
| 11 |
class JsonManifestWriter:
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
+
from app.domain.types import EnhancementResult, GeneratedDerivatives, JobWorkspace, ResolvedProcessRequest
|
| 9 |
|
| 10 |
|
| 11 |
class JsonManifestWriter:
|
app/application/services/setup/job_workspace.py
CHANGED
|
@@ -6,7 +6,7 @@ import shutil
|
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
-
from app.domain.
|
| 10 |
|
| 11 |
|
| 12 |
class LocalJobWorkspaceFactory:
|
|
|
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
+
from app.domain.types import JobWorkspace
|
| 10 |
|
| 11 |
|
| 12 |
class LocalJobWorkspaceFactory:
|
app/application/services/setup/request_options.py
CHANGED
|
@@ -2,15 +2,9 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from app.domain.
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
PipelineDefaultsModel,
|
| 9 |
-
ProcessRequestModel,
|
| 10 |
-
ResolvedProcessRequest,
|
| 11 |
-
SUPPORTED_OUTPUT_FORMATS,
|
| 12 |
-
WatermarkModel,
|
| 13 |
-
)
|
| 14 |
|
| 15 |
|
| 16 |
class DefaultProcessingOptionsResolver:
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from app.domain.constants import OutputFormat, SUPPORTED_OUTPUT_FORMATS
|
| 6 |
+
from app.domain.schemas import MetadataModel, PipelineDefaultsModel, ProcessRequestModel, WatermarkModel
|
| 7 |
+
from app.domain.types import ResolvedProcessRequest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
class DefaultProcessingOptionsResolver:
|
app/domain/constants.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
SUPPORTED_OUTPUT_FORMATS = ("avif", "jpg")
|
| 6 |
+
OutputFormat = Literal["avif", "jpg"]
|
app/domain/ports/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Domain port definitions."""
|
app/domain/ports/media.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Protocol
|
| 5 |
+
|
| 6 |
+
from app.domain.schemas import ImageSizeModel, MetadataModel, WatermarkModel
|
| 7 |
+
from app.domain.types import EnhancementResult
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Enhancer(Protocol):
|
| 11 |
+
def build_working_master(self, source_path: Path, target_path: Path) -> EnhancementResult:
|
| 12 |
+
...
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class MetadataPort(Protocol):
|
| 16 |
+
def ensure_available(self) -> None:
|
| 17 |
+
...
|
| 18 |
+
|
| 19 |
+
def write_metadata(self, file_path: Path, metadata: MetadataModel | None) -> None:
|
| 20 |
+
...
|
| 21 |
+
|
| 22 |
+
def copy_all_metadata(self, source_path: Path, target_path: Path) -> None:
|
| 23 |
+
...
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class DerivativeEncoder(Protocol):
|
| 27 |
+
def ensure_available(self) -> None:
|
| 28 |
+
...
|
| 29 |
+
|
| 30 |
+
def encode(
|
| 31 |
+
self,
|
| 32 |
+
source_path: Path,
|
| 33 |
+
target_path: Path,
|
| 34 |
+
size: ImageSizeModel,
|
| 35 |
+
image_format: str,
|
| 36 |
+
watermark_png_path: Path | None,
|
| 37 |
+
watermark: WatermarkModel | None,
|
| 38 |
+
) -> None:
|
| 39 |
+
...
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class WatermarkAssetResolver(Protocol):
|
| 43 |
+
def resolve_png(self, watermark: WatermarkModel, job_dir: Path) -> Path:
|
| 44 |
+
...
|
app/domain/{interfaces.py → ports/pipeline.py}
RENAMED
|
@@ -3,59 +3,8 @@ from __future__ import annotations
|
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import Any, Protocol
|
| 5 |
|
| 6 |
-
from app.domain.
|
| 7 |
-
|
| 8 |
-
FetchedSource,
|
| 9 |
-
GeneratedDerivatives,
|
| 10 |
-
ImageSizeModel,
|
| 11 |
-
JobWorkspace,
|
| 12 |
-
MetadataModel,
|
| 13 |
-
ProcessRequestModel,
|
| 14 |
-
ResolvedProcessRequest,
|
| 15 |
-
WatermarkModel,
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
class Enhancer(Protocol):
|
| 20 |
-
def build_working_master(self, source_path: Path, target_path: Path) -> EnhancementResult:
|
| 21 |
-
...
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
class MetadataPort(Protocol):
|
| 25 |
-
def ensure_available(self) -> None:
|
| 26 |
-
...
|
| 27 |
-
|
| 28 |
-
def write_metadata(self, file_path: Path, metadata: MetadataModel | None) -> None:
|
| 29 |
-
...
|
| 30 |
-
|
| 31 |
-
def copy_all_metadata(self, source_path: Path, target_path: Path) -> None:
|
| 32 |
-
...
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class DerivativeEncoder(Protocol):
|
| 36 |
-
def ensure_available(self) -> None:
|
| 37 |
-
...
|
| 38 |
-
|
| 39 |
-
def encode(
|
| 40 |
-
self,
|
| 41 |
-
source_path: Path,
|
| 42 |
-
target_path: Path,
|
| 43 |
-
size: ImageSizeModel,
|
| 44 |
-
image_format: str,
|
| 45 |
-
watermark_png_path: Path | None,
|
| 46 |
-
watermark: WatermarkModel | None,
|
| 47 |
-
) -> None:
|
| 48 |
-
...
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
class WatermarkAssetResolver(Protocol):
|
| 52 |
-
def resolve_png(self, watermark: WatermarkModel, job_dir: Path) -> Path:
|
| 53 |
-
...
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class AssetUploader(Protocol):
|
| 57 |
-
def upload_file(self, file_path: Path, object_key: str, content_type: str) -> dict[str, str]:
|
| 58 |
-
...
|
| 59 |
|
| 60 |
|
| 61 |
class ProcessingOptionsResolver(Protocol):
|
|
@@ -93,13 +42,3 @@ class ManifestWriter(Protocol):
|
|
| 93 |
generated: GeneratedDerivatives,
|
| 94 |
) -> dict[str, Any]:
|
| 95 |
...
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
class RemoteSourceFetcher(Protocol):
|
| 99 |
-
def download(self, source_url: str) -> FetchedSource:
|
| 100 |
-
...
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
class UploadSourceStager(Protocol):
|
| 104 |
-
def stage(self, file_name: str | None, content: bytes) -> FetchedSource:
|
| 105 |
-
...
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import Any, Protocol
|
| 5 |
|
| 6 |
+
from app.domain.schemas import ProcessRequestModel
|
| 7 |
+
from app.domain.types import EnhancementResult, GeneratedDerivatives, JobWorkspace, ResolvedProcessRequest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
class ProcessingOptionsResolver(Protocol):
|
|
|
|
| 42 |
generated: GeneratedDerivatives,
|
| 43 |
) -> dict[str, Any]:
|
| 44 |
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/domain/ports/sources.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Protocol
|
| 5 |
+
|
| 6 |
+
from app.domain.types import FetchedSource
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AssetUploader(Protocol):
|
| 10 |
+
def upload_file(self, file_path: Path, object_key: str, content_type: str) -> dict[str, str]:
|
| 11 |
+
...
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RemoteSourceFetcher(Protocol):
|
| 15 |
+
def download(self, source_url: str) -> FetchedSource:
|
| 16 |
+
...
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class UploadSourceStager(Protocol):
|
| 20 |
+
def stage(self, file_name: str | None, content: bytes) -> FetchedSource:
|
| 21 |
+
...
|
app/domain/{models.py → schemas.py}
RENAMED
|
@@ -1,15 +1,13 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from dataclasses import dataclass
|
| 4 |
-
from pathlib import Path
|
| 5 |
from typing import Literal
|
| 6 |
|
| 7 |
from pydantic import BaseModel, Field, model_validator
|
| 8 |
|
|
|
|
|
|
|
| 9 |
MetadataScalar = str | int | float | bool
|
| 10 |
MetadataValue = MetadataScalar | list[MetadataScalar]
|
| 11 |
-
SUPPORTED_OUTPUT_FORMATS = ("avif", "jpg")
|
| 12 |
-
OutputFormat = Literal["avif", "jpg"]
|
| 13 |
|
| 14 |
|
| 15 |
class ImageSizeModel(BaseModel):
|
|
@@ -185,67 +183,3 @@ class ProcessRequestModel(BaseModel):
|
|
| 185 |
]
|
| 186 |
},
|
| 187 |
}
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
@dataclass(frozen=True)
|
| 191 |
-
class RuntimeSettings:
|
| 192 |
-
host: str
|
| 193 |
-
port: int
|
| 194 |
-
output_dir: Path
|
| 195 |
-
device: str
|
| 196 |
-
max_input_side: int
|
| 197 |
-
restore_original_size: bool
|
| 198 |
-
jpeg_quality: int
|
| 199 |
-
brightness_skip_threshold: float
|
| 200 |
-
hvi_model_id: str
|
| 201 |
-
hvi_gamma: float
|
| 202 |
-
hvi_alpha_s: float
|
| 203 |
-
hvi_alpha_i: float
|
| 204 |
-
default_formats: tuple[str, ...]
|
| 205 |
-
config_path: Path
|
| 206 |
-
r2_config: R2ConfigModel | None
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
@dataclass(frozen=True)
|
| 210 |
-
class EnhancementResult:
|
| 211 |
-
path: Path
|
| 212 |
-
brightness: float
|
| 213 |
-
skipped: bool
|
| 214 |
-
strategy: str
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
@dataclass(frozen=True)
|
| 218 |
-
class ResolvedProcessRequest:
|
| 219 |
-
sizes: tuple[ImageSizeModel, ...]
|
| 220 |
-
formats: tuple[OutputFormat, ...]
|
| 221 |
-
metadata: MetadataModel | None
|
| 222 |
-
watermark: WatermarkModel | None
|
| 223 |
-
upload_to_r2: bool
|
| 224 |
-
r2_prefix: str | None
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
@dataclass(frozen=True)
|
| 228 |
-
class JobWorkspace:
|
| 229 |
-
job_id: str
|
| 230 |
-
job_dir: Path
|
| 231 |
-
staged_source_path: Path
|
| 232 |
-
working_master_path: Path
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
@dataclass(frozen=True)
|
| 236 |
-
class OutputArtifact:
|
| 237 |
-
image_format: str
|
| 238 |
-
size: ImageSizeModel
|
| 239 |
-
file_path: Path
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
@dataclass(frozen=True)
|
| 243 |
-
class GeneratedDerivatives:
|
| 244 |
-
outputs: tuple[OutputArtifact, ...]
|
| 245 |
-
r2_summary: dict[str, object] | None
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
@dataclass(frozen=True)
|
| 249 |
-
class FetchedSource:
|
| 250 |
-
temp_path: Path
|
| 251 |
-
display_name: str
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from typing import Literal
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field, model_validator
|
| 6 |
|
| 7 |
+
from app.domain.constants import OutputFormat
|
| 8 |
+
|
| 9 |
MetadataScalar = str | int | float | bool
|
| 10 |
MetadataValue = MetadataScalar | list[MetadataScalar]
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class ImageSizeModel(BaseModel):
|
|
|
|
| 183 |
]
|
| 184 |
},
|
| 185 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/domain/types.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from app.domain.constants import OutputFormat
|
| 7 |
+
from app.domain.schemas import ImageSizeModel, MetadataModel, R2ConfigModel, WatermarkModel
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class RuntimeSettings:
|
| 12 |
+
host: str
|
| 13 |
+
port: int
|
| 14 |
+
output_dir: Path
|
| 15 |
+
device: str
|
| 16 |
+
max_input_side: int
|
| 17 |
+
restore_original_size: bool
|
| 18 |
+
jpeg_quality: int
|
| 19 |
+
brightness_skip_threshold: float
|
| 20 |
+
hvi_model_id: str
|
| 21 |
+
hvi_gamma: float
|
| 22 |
+
hvi_alpha_s: float
|
| 23 |
+
hvi_alpha_i: float
|
| 24 |
+
default_formats: tuple[str, ...]
|
| 25 |
+
config_path: Path
|
| 26 |
+
r2_config: R2ConfigModel | None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class EnhancementResult:
|
| 31 |
+
path: Path
|
| 32 |
+
brightness: float
|
| 33 |
+
skipped: bool
|
| 34 |
+
strategy: str
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class ResolvedProcessRequest:
|
| 39 |
+
sizes: tuple[ImageSizeModel, ...]
|
| 40 |
+
formats: tuple[OutputFormat, ...]
|
| 41 |
+
metadata: MetadataModel | None
|
| 42 |
+
watermark: WatermarkModel | None
|
| 43 |
+
upload_to_r2: bool
|
| 44 |
+
r2_prefix: str | None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True)
|
| 48 |
+
class JobWorkspace:
|
| 49 |
+
job_id: str
|
| 50 |
+
job_dir: Path
|
| 51 |
+
staged_source_path: Path
|
| 52 |
+
working_master_path: Path
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass(frozen=True)
|
| 56 |
+
class OutputArtifact:
|
| 57 |
+
image_format: str
|
| 58 |
+
size: ImageSizeModel
|
| 59 |
+
file_path: Path
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass(frozen=True)
|
| 63 |
+
class GeneratedDerivatives:
|
| 64 |
+
outputs: tuple[OutputArtifact, ...]
|
| 65 |
+
r2_summary: dict[str, object] | None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass(frozen=True)
|
| 69 |
+
class FetchedSource:
|
| 70 |
+
temp_path: Path
|
| 71 |
+
display_name: str
|
app/infrastructure/composition/dependencies.py
CHANGED
|
@@ -10,7 +10,8 @@ from app.application.services.outputs.derivative_generator import DefaultDerivat
|
|
| 10 |
from app.application.services.outputs.manifest_writer import JsonManifestWriter
|
| 11 |
from app.application.services.setup.job_workspace import LocalJobWorkspaceFactory
|
| 12 |
from app.application.services.setup.request_options import DefaultProcessingOptionsResolver
|
| 13 |
-
from app.domain.
|
|
|
|
| 14 |
from app.infrastructure.config.config_loader import load_pipeline_defaults, load_settings
|
| 15 |
from app.infrastructure.media.command_runner import CommandRunner
|
| 16 |
from app.infrastructure.media.encoder import ImageEncoder
|
|
|
|
| 10 |
from app.application.services.outputs.manifest_writer import JsonManifestWriter
|
| 11 |
from app.application.services.setup.job_workspace import LocalJobWorkspaceFactory
|
| 12 |
from app.application.services.setup.request_options import DefaultProcessingOptionsResolver
|
| 13 |
+
from app.domain.schemas import PipelineDefaultsModel
|
| 14 |
+
from app.domain.types import RuntimeSettings
|
| 15 |
from app.infrastructure.config.config_loader import load_pipeline_defaults, load_settings
|
| 16 |
from app.infrastructure.media.command_runner import CommandRunner
|
| 17 |
from app.infrastructure.media.encoder import ImageEncoder
|
app/infrastructure/config/config_loader.py
CHANGED
|
@@ -6,7 +6,8 @@ from pathlib import Path
|
|
| 6 |
|
| 7 |
from dotenv import dotenv_values
|
| 8 |
|
| 9 |
-
from app.domain.
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def load_settings(project_root: Path) -> RuntimeSettings:
|
|
|
|
| 6 |
|
| 7 |
from dotenv import dotenv_values
|
| 8 |
|
| 9 |
+
from app.domain.schemas import PipelineDefaultsModel, R2ConfigModel
|
| 10 |
+
from app.domain.types import RuntimeSettings
|
| 11 |
|
| 12 |
|
| 13 |
def load_settings(project_root: Path) -> RuntimeSettings:
|
app/infrastructure/media/encoder.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
from app.domain.
|
| 6 |
from app.infrastructure.media.command_runner import CommandRunner
|
| 7 |
|
| 8 |
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
from app.domain.schemas import ImageSizeModel, WatermarkModel
|
| 6 |
from app.infrastructure.media.command_runner import CommandRunner
|
| 7 |
|
| 8 |
|
app/infrastructure/media/enhancer.py
CHANGED
|
@@ -7,7 +7,7 @@ from huggingface_hub import hf_hub_download
|
|
| 7 |
from PIL import Image
|
| 8 |
from safetensors.torch import load_file
|
| 9 |
|
| 10 |
-
from app.domain.
|
| 11 |
from app.infrastructure.media.image_ops import (
|
| 12 |
estimate_brightness,
|
| 13 |
load_image_as_tensor,
|
|
|
|
| 7 |
from PIL import Image
|
| 8 |
from safetensors.torch import load_file
|
| 9 |
|
| 10 |
+
from app.domain.types import EnhancementResult, RuntimeSettings
|
| 11 |
from app.infrastructure.media.image_ops import (
|
| 12 |
estimate_brightness,
|
| 13 |
load_image_as_tensor,
|
app/infrastructure/media/metadata_service.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import tempfile
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
-
from app.domain.
|
| 7 |
from app.infrastructure.media.command_runner import CommandRunner
|
| 8 |
|
| 9 |
|
|
|
|
| 3 |
import tempfile
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
+
from app.domain.schemas import MetadataModel
|
| 7 |
from app.infrastructure.media.command_runner import CommandRunner
|
| 8 |
|
| 9 |
|
app/infrastructure/media/watermark.py
CHANGED
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
| 4 |
|
| 5 |
from PIL import Image, ImageDraw, ImageFont
|
| 6 |
|
| 7 |
-
from app.domain.
|
| 8 |
|
| 9 |
|
| 10 |
class WatermarkAssetFactory:
|
|
|
|
| 4 |
|
| 5 |
from PIL import Image, ImageDraw, ImageFont
|
| 6 |
|
| 7 |
+
from app.domain.schemas import WatermarkModel
|
| 8 |
|
| 9 |
|
| 10 |
class WatermarkAssetFactory:
|
app/infrastructure/sources/remote_source_fetcher.py
CHANGED
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
| 5 |
from urllib.parse import urlparse
|
| 6 |
from urllib.request import Request, urlopen
|
| 7 |
|
| 8 |
-
from app.domain.
|
| 9 |
|
| 10 |
|
| 11 |
class RemoteImageFetcher:
|
|
|
|
| 5 |
from urllib.parse import urlparse
|
| 6 |
from urllib.request import Request, urlopen
|
| 7 |
|
| 8 |
+
from app.domain.types import FetchedSource
|
| 9 |
|
| 10 |
|
| 11 |
class RemoteImageFetcher:
|
app/infrastructure/sources/upload_source_stager.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import tempfile
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
-
from app.domain.
|
| 7 |
|
| 8 |
|
| 9 |
class TemporaryUploadSourceStager:
|
|
|
|
| 3 |
import tempfile
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
+
from app.domain.types import FetchedSource
|
| 7 |
|
| 8 |
|
| 9 |
class TemporaryUploadSourceStager:
|
app/infrastructure/storage/r2_uploader.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
from app.domain.
|
| 6 |
|
| 7 |
|
| 8 |
class R2Uploader:
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
+
from app.domain.schemas import R2ConfigModel
|
| 6 |
|
| 7 |
|
| 8 |
class R2Uploader:
|
tests/demo.jpg
ADDED
|
Git LFS Details
|
tests/integration/api/support.py
CHANGED
|
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|
| 3 |
from pathlib import Path
|
| 4 |
from types import SimpleNamespace
|
| 5 |
|
| 6 |
-
from app.domain.
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class FakeProcessRemoteImage:
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
from types import SimpleNamespace
|
| 5 |
|
| 6 |
+
from app.domain.schemas import PipelineDefaultsModel
|
| 7 |
+
from app.domain.types import RuntimeSettings
|
| 8 |
|
| 9 |
|
| 10 |
class FakeProcessRemoteImage:
|
tests/integration/api/test_process_upload.py
CHANGED
|
@@ -13,6 +13,37 @@ from tests.integration.api.support import FakeProcessRemoteImage, FakeProcessUpl
|
|
| 13 |
|
| 14 |
|
| 15 |
class ApiProcessUploadIntegrationTests(unittest.TestCase):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def test_process_upload_returns_job_id_on_success(self) -> None:
|
| 17 |
with tempfile.TemporaryDirectory() as temp_dir:
|
| 18 |
root = Path(temp_dir)
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class ApiProcessUploadIntegrationTests(unittest.TestCase):
|
| 16 |
+
def test_process_upload_accepts_real_demo_image_file(self) -> None:
|
| 17 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 18 |
+
root = Path(temp_dir)
|
| 19 |
+
process_uploaded_image = FakeProcessUploadedImage()
|
| 20 |
+
app = create_app()
|
| 21 |
+
app.dependency_overrides[get_container] = lambda: make_container(
|
| 22 |
+
root,
|
| 23 |
+
process_remote_image=FakeProcessRemoteImage(),
|
| 24 |
+
process_uploaded_image=process_uploaded_image,
|
| 25 |
+
)
|
| 26 |
+
client = TestClient(app)
|
| 27 |
+
demo_path = Path(__file__).resolve().parents[2] / "demo.jpg"
|
| 28 |
+
expected_bytes = demo_path.read_bytes()
|
| 29 |
+
try:
|
| 30 |
+
with demo_path.open("rb") as image_file:
|
| 31 |
+
response = client.post(
|
| 32 |
+
"/process/upload",
|
| 33 |
+
files={"file": ("demo.jpg", image_file, "image/jpeg")},
|
| 34 |
+
data={"options": '{"formats":["jpg"]}'},
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
self.assertEqual(status.HTTP_200_OK, response.status_code)
|
| 38 |
+
self.assertEqual("test-upload-job", response.json()["jobId"])
|
| 39 |
+
self.assertEqual(1, len(process_uploaded_image.calls))
|
| 40 |
+
self.assertEqual("demo.jpg", process_uploaded_image.calls[0][0])
|
| 41 |
+
self.assertEqual(expected_bytes, process_uploaded_image.calls[0][1])
|
| 42 |
+
self.assertEqual(["jpg"], process_uploaded_image.calls[0][2].formats)
|
| 43 |
+
finally:
|
| 44 |
+
client.close()
|
| 45 |
+
app.dependency_overrides.clear()
|
| 46 |
+
|
| 47 |
def test_process_upload_returns_job_id_on_success(self) -> None:
|
| 48 |
with tempfile.TemporaryDirectory() as temp_dir:
|
| 49 |
root = Path(temp_dir)
|
tests/unit/application/services/orchestration/test_process_image.py
CHANGED
|
@@ -5,14 +5,8 @@ import unittest
|
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 8 |
-
from app.domain.
|
| 9 |
-
|
| 10 |
-
JobWorkspace,
|
| 11 |
-
WatermarkModel,
|
| 12 |
-
PipelineDefaultsModel,
|
| 13 |
-
ProcessRequestModel,
|
| 14 |
-
ResolvedProcessRequest,
|
| 15 |
-
)
|
| 16 |
|
| 17 |
|
| 18 |
class _FakeOptionsResolver:
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
from app.application.services.orchestration.process_image import ProcessImageUseCase
|
| 8 |
+
from app.domain.schemas import PipelineDefaultsModel, ProcessRequestModel, WatermarkModel
|
| 9 |
+
from app.domain.types import EnhancementResult, JobWorkspace, ResolvedProcessRequest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class _FakeOptionsResolver:
|
tests/unit/application/services/orchestration/test_process_sources.py
CHANGED
|
@@ -6,7 +6,8 @@ from pathlib import Path
|
|
| 6 |
|
| 7 |
from app.application.services.orchestration.process_remote_image import ProcessRemoteImageUseCase
|
| 8 |
from app.application.services.orchestration.process_upload_image import ProcessUploadedImageUseCase
|
| 9 |
-
from app.domain.
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class _FakeRemoteSourceFetcher:
|
|
|
|
| 6 |
|
| 7 |
from app.application.services.orchestration.process_remote_image import ProcessRemoteImageUseCase
|
| 8 |
from app.application.services.orchestration.process_upload_image import ProcessUploadedImageUseCase
|
| 9 |
+
from app.domain.schemas import ProcessRequestModel
|
| 10 |
+
from app.domain.types import FetchedSource
|
| 11 |
|
| 12 |
|
| 13 |
class _FakeRemoteSourceFetcher:
|
tests/unit/application/services/outputs/test_derivative_generator.py
CHANGED
|
@@ -5,7 +5,8 @@ import unittest
|
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
from app.application.services.outputs.derivative_generator import DefaultDerivativeGenerator
|
| 8 |
-
from app.domain.
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class _FakeEncoder:
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
from app.application.services.outputs.derivative_generator import DefaultDerivativeGenerator
|
| 8 |
+
from app.domain.schemas import ImageSizeModel, R2ConfigModel
|
| 9 |
+
from app.domain.types import GeneratedDerivatives, JobWorkspace, ResolvedProcessRequest
|
| 10 |
|
| 11 |
|
| 12 |
class _FakeEncoder:
|
tests/unit/application/services/outputs/test_manifest_writer.py
CHANGED
|
@@ -6,15 +6,13 @@ import unittest
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
from app.application.services.outputs.manifest_writer import JsonManifestWriter
|
| 9 |
-
from app.domain.
|
|
|
|
| 10 |
EnhancementResult,
|
| 11 |
GeneratedDerivatives,
|
| 12 |
-
ImageSizeModel,
|
| 13 |
JobWorkspace,
|
| 14 |
-
MetadataModel,
|
| 15 |
OutputArtifact,
|
| 16 |
ResolvedProcessRequest,
|
| 17 |
-
WatermarkModel,
|
| 18 |
)
|
| 19 |
|
| 20 |
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
from app.application.services.outputs.manifest_writer import JsonManifestWriter
|
| 9 |
+
from app.domain.schemas import ImageSizeModel, MetadataModel, WatermarkModel
|
| 10 |
+
from app.domain.types import (
|
| 11 |
EnhancementResult,
|
| 12 |
GeneratedDerivatives,
|
|
|
|
| 13 |
JobWorkspace,
|
|
|
|
| 14 |
OutputArtifact,
|
| 15 |
ResolvedProcessRequest,
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
tests/unit/application/services/setup/test_request_options.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import unittest
|
| 4 |
|
| 5 |
from app.application.services.setup.request_options import DefaultProcessingOptionsResolver
|
| 6 |
-
from app.domain.
|
| 7 |
ImageSizeModel,
|
| 8 |
MetadataModel,
|
| 9 |
PipelineDefaultsModel,
|
|
|
|
| 3 |
import unittest
|
| 4 |
|
| 5 |
from app.application.services.setup.request_options import DefaultProcessingOptionsResolver
|
| 6 |
+
from app.domain.schemas import (
|
| 7 |
ImageSizeModel,
|
| 8 |
MetadataModel,
|
| 9 |
PipelineDefaultsModel,
|
tests/unit/infrastructure/media/test_metadata_service.py
CHANGED
|
@@ -4,7 +4,7 @@ import tempfile
|
|
| 4 |
import unittest
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
-
from app.domain.
|
| 8 |
from app.infrastructure.media.metadata_service import MetadataService
|
| 9 |
|
| 10 |
|
|
|
|
| 4 |
import unittest
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
+
from app.domain.schemas import MetadataModel
|
| 8 |
from app.infrastructure.media.metadata_service import MetadataService
|
| 9 |
|
| 10 |
|
tests/unit/infrastructure/media/test_watermark.py
CHANGED
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
| 6 |
|
| 7 |
from PIL import Image
|
| 8 |
|
| 9 |
-
from app.domain.
|
| 10 |
from app.infrastructure.media.watermark import WatermarkAssetFactory
|
| 11 |
|
| 12 |
|
|
|
|
| 6 |
|
| 7 |
from PIL import Image
|
| 8 |
|
| 9 |
+
from app.domain.schemas import WatermarkModel
|
| 10 |
from app.infrastructure.media.watermark import WatermarkAssetFactory
|
| 11 |
|
| 12 |
|