bh-pixelfuse 1.0.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.
- bh_pixelfuse-1.0.0/PKG-INFO +82 -0
- bh_pixelfuse-1.0.0/README.md +62 -0
- bh_pixelfuse-1.0.0/pyproject.toml +63 -0
- bh_pixelfuse-1.0.0/setup.cfg +4 -0
- bh_pixelfuse-1.0.0/src/bh_pixelfuse.egg-info/PKG-INFO +82 -0
- bh_pixelfuse-1.0.0/src/bh_pixelfuse.egg-info/SOURCES.txt +18 -0
- bh_pixelfuse-1.0.0/src/bh_pixelfuse.egg-info/dependency_links.txt +1 -0
- bh_pixelfuse-1.0.0/src/bh_pixelfuse.egg-info/entry_points.txt +2 -0
- bh_pixelfuse-1.0.0/src/bh_pixelfuse.egg-info/requires.txt +14 -0
- bh_pixelfuse-1.0.0/src/bh_pixelfuse.egg-info/top_level.txt +1 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/__init__.py +1 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/api/__init__.py +0 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/api/routes/__init__.py +0 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/api/routes/convert.py +49 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/api/routes/extract.py +52 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/cli.py +47 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/config.py +25 -0
- bh_pixelfuse-1.0.0/src/pixelfuse/main.py +41 -0
- bh_pixelfuse-1.0.0/tests/test_convert.py +34 -0
- bh_pixelfuse-1.0.0/tests/test_extract.py +33 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bh_pixelfuse
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Embed images into portable text files and extract them back
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi>=0.115.0
|
|
8
|
+
Requires-Dist: uvicorn[standard]>=0.31.0
|
|
9
|
+
Requires-Dist: Pillow>=10.0.0
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.12
|
|
11
|
+
Requires-Dist: pydantic>=2.9.0
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.6.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
16
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
17
|
+
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
19
|
+
Requires-Dist: tox>=4.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# PixelFuse Backend
|
|
22
|
+
|
|
23
|
+
FastAPI service that embeds images into portable text files and extracts them back.
|
|
24
|
+
|
|
25
|
+
## Docs
|
|
26
|
+
|
|
27
|
+
| # | File | Description |
|
|
28
|
+
|---|------|-------------|
|
|
29
|
+
| 001 | [Overview](docs/001_overview.md) | What PixelFuse does and the text file format |
|
|
30
|
+
| 002 | [API Reference](docs/002_api.md) | Endpoint contracts, fields, and error codes |
|
|
31
|
+
| 003 | [Configuration](docs/003_configuration.md) | Env vars and CLI flags |
|
|
32
|
+
| 004 | [Development](docs/004_development.md) | Local setup, tests, lint, Docker |
|
|
33
|
+
| 005 | [Deployment](docs/005_deployment.md) | Render, CI, self-hosted Docker |
|
|
34
|
+
|
|
35
|
+
## Endpoints
|
|
36
|
+
|
|
37
|
+
| Method | Path | Description |
|
|
38
|
+
|--------|------|-------------|
|
|
39
|
+
| POST | `/convert-embed/` | Upload images → download base64 text file |
|
|
40
|
+
| POST | `/extract-images/` | Upload text file → download ZIP of images |
|
|
41
|
+
|
|
42
|
+
Supports JPEG, PNG, and HEIC formats. Max 10 files per request (configurable).
|
|
43
|
+
|
|
44
|
+
**No distortion.** Embed → extract is lossless. Raw image bytes are base64-encoded and decoded back exactly — no re-encoding at any stage.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
pixelfuse serve --reload
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
All settings are overridable via environment variables prefixed `PIXELFUSE_`:
|
|
56
|
+
|
|
57
|
+
| Variable | Default | Description |
|
|
58
|
+
|----------|---------|-------------|
|
|
59
|
+
| `PIXELFUSE_HOST` | `0.0.0.0` | Bind host |
|
|
60
|
+
| `PIXELFUSE_PORT` | `8000` | Bind port |
|
|
61
|
+
| `PIXELFUSE_LOG_LEVEL` | `info` | Uvicorn log level |
|
|
62
|
+
| `PIXELFUSE_MAX_UPLOAD_FILES` | `10` | Max files per request |
|
|
63
|
+
| `PIXELFUSE_ALLOWED_ORIGINS` | *(required)* | CORS origins (JSON array) |
|
|
64
|
+
|
|
65
|
+
Copy `.env.example` to `.env` for local overrides.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Lint
|
|
71
|
+
flake8 src tests
|
|
72
|
+
|
|
73
|
+
# Type check
|
|
74
|
+
mypy src
|
|
75
|
+
|
|
76
|
+
# All environments
|
|
77
|
+
tox
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Deployment
|
|
81
|
+
|
|
82
|
+
Deployed on [Render](https://render.com) via `render.yaml`. CI runs on every push via GitHub Actions.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# PixelFuse Backend
|
|
2
|
+
|
|
3
|
+
FastAPI service that embeds images into portable text files and extracts them back.
|
|
4
|
+
|
|
5
|
+
## Docs
|
|
6
|
+
|
|
7
|
+
| # | File | Description |
|
|
8
|
+
|---|------|-------------|
|
|
9
|
+
| 001 | [Overview](docs/001_overview.md) | What PixelFuse does and the text file format |
|
|
10
|
+
| 002 | [API Reference](docs/002_api.md) | Endpoint contracts, fields, and error codes |
|
|
11
|
+
| 003 | [Configuration](docs/003_configuration.md) | Env vars and CLI flags |
|
|
12
|
+
| 004 | [Development](docs/004_development.md) | Local setup, tests, lint, Docker |
|
|
13
|
+
| 005 | [Deployment](docs/005_deployment.md) | Render, CI, self-hosted Docker |
|
|
14
|
+
|
|
15
|
+
## Endpoints
|
|
16
|
+
|
|
17
|
+
| Method | Path | Description |
|
|
18
|
+
|--------|------|-------------|
|
|
19
|
+
| POST | `/convert-embed/` | Upload images → download base64 text file |
|
|
20
|
+
| POST | `/extract-images/` | Upload text file → download ZIP of images |
|
|
21
|
+
|
|
22
|
+
Supports JPEG, PNG, and HEIC formats. Max 10 files per request (configurable).
|
|
23
|
+
|
|
24
|
+
**No distortion.** Embed → extract is lossless. Raw image bytes are base64-encoded and decoded back exactly — no re-encoding at any stage.
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -e ".[dev]"
|
|
30
|
+
pixelfuse serve --reload
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
All settings are overridable via environment variables prefixed `PIXELFUSE_`:
|
|
36
|
+
|
|
37
|
+
| Variable | Default | Description |
|
|
38
|
+
|----------|---------|-------------|
|
|
39
|
+
| `PIXELFUSE_HOST` | `0.0.0.0` | Bind host |
|
|
40
|
+
| `PIXELFUSE_PORT` | `8000` | Bind port |
|
|
41
|
+
| `PIXELFUSE_LOG_LEVEL` | `info` | Uvicorn log level |
|
|
42
|
+
| `PIXELFUSE_MAX_UPLOAD_FILES` | `10` | Max files per request |
|
|
43
|
+
| `PIXELFUSE_ALLOWED_ORIGINS` | *(required)* | CORS origins (JSON array) |
|
|
44
|
+
|
|
45
|
+
Copy `.env.example` to `.env` for local overrides.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Lint
|
|
51
|
+
flake8 src tests
|
|
52
|
+
|
|
53
|
+
# Type check
|
|
54
|
+
mypy src
|
|
55
|
+
|
|
56
|
+
# All environments
|
|
57
|
+
tox
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Deployment
|
|
61
|
+
|
|
62
|
+
Deployed on [Render](https://render.com) via `render.yaml`. CI runs on every push via GitHub Actions.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bh_pixelfuse"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Embed images into portable text files and extract them back"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastapi>=0.115.0",
|
|
13
|
+
"uvicorn[standard]>=0.31.0",
|
|
14
|
+
"Pillow>=10.0.0",
|
|
15
|
+
"python-multipart>=0.0.12",
|
|
16
|
+
"pydantic>=2.9.0",
|
|
17
|
+
"pydantic-settings>=2.6.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
"pytest-asyncio>=0.24",
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
"flake8>=7.0",
|
|
26
|
+
"mypy>=1.11",
|
|
27
|
+
"tox>=4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
pixelfuse = "pixelfuse.cli:cli"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
python_version = "3.11"
|
|
39
|
+
strict = true
|
|
40
|
+
ignore_missing_imports = true
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
|
|
46
|
+
[tool.tox]
|
|
47
|
+
legacy_tox_ini = """
|
|
48
|
+
[tox]
|
|
49
|
+
envlist = lint, typecheck, test
|
|
50
|
+
isolated_build = true
|
|
51
|
+
|
|
52
|
+
[testenv:lint]
|
|
53
|
+
deps = flake8>=7.0
|
|
54
|
+
commands = flake8 src tests
|
|
55
|
+
|
|
56
|
+
[testenv:typecheck]
|
|
57
|
+
deps = mypy>=1.11
|
|
58
|
+
commands = mypy src
|
|
59
|
+
|
|
60
|
+
[testenv:test]
|
|
61
|
+
extras = dev
|
|
62
|
+
commands = pytest --tb=short -q
|
|
63
|
+
"""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bh_pixelfuse
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Embed images into portable text files and extract them back
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi>=0.115.0
|
|
8
|
+
Requires-Dist: uvicorn[standard]>=0.31.0
|
|
9
|
+
Requires-Dist: Pillow>=10.0.0
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.12
|
|
11
|
+
Requires-Dist: pydantic>=2.9.0
|
|
12
|
+
Requires-Dist: pydantic-settings>=2.6.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
16
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
17
|
+
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
19
|
+
Requires-Dist: tox>=4.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# PixelFuse Backend
|
|
22
|
+
|
|
23
|
+
FastAPI service that embeds images into portable text files and extracts them back.
|
|
24
|
+
|
|
25
|
+
## Docs
|
|
26
|
+
|
|
27
|
+
| # | File | Description |
|
|
28
|
+
|---|------|-------------|
|
|
29
|
+
| 001 | [Overview](docs/001_overview.md) | What PixelFuse does and the text file format |
|
|
30
|
+
| 002 | [API Reference](docs/002_api.md) | Endpoint contracts, fields, and error codes |
|
|
31
|
+
| 003 | [Configuration](docs/003_configuration.md) | Env vars and CLI flags |
|
|
32
|
+
| 004 | [Development](docs/004_development.md) | Local setup, tests, lint, Docker |
|
|
33
|
+
| 005 | [Deployment](docs/005_deployment.md) | Render, CI, self-hosted Docker |
|
|
34
|
+
|
|
35
|
+
## Endpoints
|
|
36
|
+
|
|
37
|
+
| Method | Path | Description |
|
|
38
|
+
|--------|------|-------------|
|
|
39
|
+
| POST | `/convert-embed/` | Upload images → download base64 text file |
|
|
40
|
+
| POST | `/extract-images/` | Upload text file → download ZIP of images |
|
|
41
|
+
|
|
42
|
+
Supports JPEG, PNG, and HEIC formats. Max 10 files per request (configurable).
|
|
43
|
+
|
|
44
|
+
**No distortion.** Embed → extract is lossless. Raw image bytes are base64-encoded and decoded back exactly — no re-encoding at any stage.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
pixelfuse serve --reload
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
All settings are overridable via environment variables prefixed `PIXELFUSE_`:
|
|
56
|
+
|
|
57
|
+
| Variable | Default | Description |
|
|
58
|
+
|----------|---------|-------------|
|
|
59
|
+
| `PIXELFUSE_HOST` | `0.0.0.0` | Bind host |
|
|
60
|
+
| `PIXELFUSE_PORT` | `8000` | Bind port |
|
|
61
|
+
| `PIXELFUSE_LOG_LEVEL` | `info` | Uvicorn log level |
|
|
62
|
+
| `PIXELFUSE_MAX_UPLOAD_FILES` | `10` | Max files per request |
|
|
63
|
+
| `PIXELFUSE_ALLOWED_ORIGINS` | *(required)* | CORS origins (JSON array) |
|
|
64
|
+
|
|
65
|
+
Copy `.env.example` to `.env` for local overrides.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Lint
|
|
71
|
+
flake8 src tests
|
|
72
|
+
|
|
73
|
+
# Type check
|
|
74
|
+
mypy src
|
|
75
|
+
|
|
76
|
+
# All environments
|
|
77
|
+
tox
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Deployment
|
|
81
|
+
|
|
82
|
+
Deployed on [Render](https://render.com) via `render.yaml`. CI runs on every push via GitHub Actions.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/bh_pixelfuse.egg-info/PKG-INFO
|
|
4
|
+
src/bh_pixelfuse.egg-info/SOURCES.txt
|
|
5
|
+
src/bh_pixelfuse.egg-info/dependency_links.txt
|
|
6
|
+
src/bh_pixelfuse.egg-info/entry_points.txt
|
|
7
|
+
src/bh_pixelfuse.egg-info/requires.txt
|
|
8
|
+
src/bh_pixelfuse.egg-info/top_level.txt
|
|
9
|
+
src/pixelfuse/__init__.py
|
|
10
|
+
src/pixelfuse/cli.py
|
|
11
|
+
src/pixelfuse/config.py
|
|
12
|
+
src/pixelfuse/main.py
|
|
13
|
+
src/pixelfuse/api/__init__.py
|
|
14
|
+
src/pixelfuse/api/routes/__init__.py
|
|
15
|
+
src/pixelfuse/api/routes/convert.py
|
|
16
|
+
src/pixelfuse/api/routes/extract.py
|
|
17
|
+
tests/test_convert.py
|
|
18
|
+
tests/test_extract.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pixelfuse
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
|
5
|
+
from fastapi.responses import Response
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from pixelfuse.config import get_settings
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.post("/convert-embed/")
|
|
14
|
+
async def convert_embed_images(
|
|
15
|
+
files: list[UploadFile] = File(...),
|
|
16
|
+
output_file_name: str = Form(...),
|
|
17
|
+
) -> Response:
|
|
18
|
+
settings = get_settings()
|
|
19
|
+
if len(files) > settings.max_upload_files:
|
|
20
|
+
raise HTTPException(
|
|
21
|
+
status_code=400,
|
|
22
|
+
detail=f"Maximum {settings.max_upload_files} files allowed.",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
blocks: list[str] = [f"Filename: {output_file_name}"]
|
|
26
|
+
|
|
27
|
+
for idx, file in enumerate(files):
|
|
28
|
+
content = await file.read()
|
|
29
|
+
filename = file.filename or f"image_{idx + 1}"
|
|
30
|
+
try:
|
|
31
|
+
if filename.lower().endswith(".heic") or file.content_type == "image/heic":
|
|
32
|
+
encoded = base64.b64encode(content).decode()
|
|
33
|
+
else:
|
|
34
|
+
Image.open(io.BytesIO(content)).verify()
|
|
35
|
+
encoded = base64.b64encode(content).decode()
|
|
36
|
+
blocks.append(f"Image {idx + 1} ({filename}):\n{encoded}")
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
raise HTTPException(
|
|
39
|
+
status_code=400,
|
|
40
|
+
detail=f"Cannot process {filename}: {exc}",
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
return Response(
|
|
44
|
+
content="\n\n".join(blocks),
|
|
45
|
+
media_type="text/plain",
|
|
46
|
+
headers={
|
|
47
|
+
"Content-Disposition": f'attachment; filename="{output_file_name}.txt"'
|
|
48
|
+
},
|
|
49
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import zipfile
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
|
6
|
+
from fastapi.responses import StreamingResponse
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post("/extract-images/")
|
|
13
|
+
async def extract_images_from_text(file: UploadFile = File(...)) -> StreamingResponse:
|
|
14
|
+
content = await file.read()
|
|
15
|
+
blocks = content.decode("utf-8").split("\n\n")
|
|
16
|
+
|
|
17
|
+
zip_buffer = io.BytesIO()
|
|
18
|
+
count = 0
|
|
19
|
+
|
|
20
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
21
|
+
for idx, block in enumerate(blocks):
|
|
22
|
+
if not block.startswith("Image"):
|
|
23
|
+
continue
|
|
24
|
+
try:
|
|
25
|
+
header, encoded = block.split(":", 1)
|
|
26
|
+
img_data = base64.b64decode(encoded.strip())
|
|
27
|
+
|
|
28
|
+
if "heic" in header.lower():
|
|
29
|
+
zf.writestr(f"image_{idx + 1}.heic", img_data)
|
|
30
|
+
else:
|
|
31
|
+
img = Image.open(io.BytesIO(img_data))
|
|
32
|
+
fmt = (img.format or "PNG").lower()
|
|
33
|
+
zf.writestr(f"image_{idx + 1}.{fmt}", img_data)
|
|
34
|
+
|
|
35
|
+
count += 1
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
raise HTTPException(
|
|
38
|
+
status_code=400,
|
|
39
|
+
detail=f"Error processing block {idx + 1}: {exc}",
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
if count == 0:
|
|
43
|
+
raise HTTPException(status_code=400, detail="No images found in file.")
|
|
44
|
+
|
|
45
|
+
zip_buffer.seek(0)
|
|
46
|
+
return StreamingResponse(
|
|
47
|
+
zip_buffer,
|
|
48
|
+
media_type="application/zip",
|
|
49
|
+
headers={
|
|
50
|
+
"Content-Disposition": 'attachment; filename="extracted_images.zip"'
|
|
51
|
+
},
|
|
52
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
from pixelfuse.config import get_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def serve(args: argparse.Namespace) -> None:
|
|
9
|
+
cfg = get_settings()
|
|
10
|
+
uvicorn.run(
|
|
11
|
+
"pixelfuse.main:app",
|
|
12
|
+
host=args.host or cfg.host,
|
|
13
|
+
port=args.port or cfg.port,
|
|
14
|
+
log_level=args.log_level or cfg.log_level,
|
|
15
|
+
reload=args.reload,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cli() -> None:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="pixelfuse",
|
|
22
|
+
description="PixelFuse — image embedding API server.",
|
|
23
|
+
)
|
|
24
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
serve_cmd = sub.add_parser("serve", help="Start the API server.")
|
|
27
|
+
serve_cmd.add_argument(
|
|
28
|
+
"--host", default=None, help="Bind host (overrides PIXELFUSE_HOST)"
|
|
29
|
+
)
|
|
30
|
+
serve_cmd.add_argument(
|
|
31
|
+
"--port", type=int, default=None, help="Bind port (overrides PIXELFUSE_PORT)"
|
|
32
|
+
)
|
|
33
|
+
serve_cmd.add_argument(
|
|
34
|
+
"--reload", action="store_true", help="Enable auto-reload (dev only)"
|
|
35
|
+
)
|
|
36
|
+
serve_cmd.add_argument(
|
|
37
|
+
"--log-level", dest="log_level", default=None,
|
|
38
|
+
help="Log level (overrides PIXELFUSE_LOG_LEVEL)",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args()
|
|
42
|
+
if args.command == "serve":
|
|
43
|
+
serve(args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
cli()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Settings(BaseSettings):
|
|
5
|
+
host: str = "0.0.0.0"
|
|
6
|
+
port: int = 8000
|
|
7
|
+
allowed_origins: list[str] = []
|
|
8
|
+
max_upload_files: int = 10
|
|
9
|
+
log_level: str = "info"
|
|
10
|
+
|
|
11
|
+
model_config = SettingsConfigDict(
|
|
12
|
+
env_file=".env",
|
|
13
|
+
env_prefix="PIXELFUSE_",
|
|
14
|
+
env_file_encoding="utf-8",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_settings: Settings | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_settings() -> Settings:
|
|
22
|
+
global _settings
|
|
23
|
+
if _settings is None:
|
|
24
|
+
_settings = Settings()
|
|
25
|
+
return _settings
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from fastapi import FastAPI, Request
|
|
2
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
3
|
+
from fastapi.responses import JSONResponse
|
|
4
|
+
|
|
5
|
+
from pixelfuse.api.routes import convert, extract
|
|
6
|
+
from pixelfuse.config import Settings, get_settings
|
|
7
|
+
|
|
8
|
+
MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20 MB
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
12
|
+
cfg = settings or get_settings()
|
|
13
|
+
app = FastAPI(
|
|
14
|
+
title="PixelFuse API",
|
|
15
|
+
description="Embed images into portable text files and extract them back.",
|
|
16
|
+
version="1.0.0",
|
|
17
|
+
)
|
|
18
|
+
app.add_middleware(
|
|
19
|
+
CORSMiddleware,
|
|
20
|
+
allow_origins=cfg.allowed_origins,
|
|
21
|
+
allow_credentials=False,
|
|
22
|
+
allow_methods=["POST"],
|
|
23
|
+
allow_headers=["Content-Type"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@app.middleware("http")
|
|
27
|
+
async def limit_upload_size(request: Request, call_next: object) -> object:
|
|
28
|
+
content_length = request.headers.get("content-length")
|
|
29
|
+
if content_length and int(content_length) > MAX_UPLOAD_BYTES:
|
|
30
|
+
return JSONResponse(
|
|
31
|
+
status_code=413,
|
|
32
|
+
content={"detail": "Upload exceeds 20 MB limit."},
|
|
33
|
+
)
|
|
34
|
+
return await call_next(request) # type: ignore[operator]
|
|
35
|
+
|
|
36
|
+
app.include_router(convert.router)
|
|
37
|
+
app.include_router(extract.router)
|
|
38
|
+
return app
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
app = create_app()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
from httpx import AsyncClient
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _make_png() -> bytes:
|
|
8
|
+
buf = io.BytesIO()
|
|
9
|
+
Image.new("RGB", (10, 10), color="red").save(buf, format="PNG")
|
|
10
|
+
return buf.getvalue()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def test_convert_embed_returns_text(client: AsyncClient) -> None:
|
|
14
|
+
response = await client.post(
|
|
15
|
+
"/convert-embed/",
|
|
16
|
+
files=[("files", ("test.png", _make_png(), "image/png"))],
|
|
17
|
+
data={"output_file_name": "output"},
|
|
18
|
+
)
|
|
19
|
+
assert response.status_code == 200
|
|
20
|
+
assert "Image 1 (test.png)" in response.text
|
|
21
|
+
assert response.headers["content-disposition"] == (
|
|
22
|
+
'attachment; filename="output.txt"'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_convert_embed_too_many_files(client: AsyncClient) -> None:
|
|
27
|
+
files = [("files", (f"img{i}.png", _make_png(), "image/png")) for i in range(11)]
|
|
28
|
+
response = await client.post(
|
|
29
|
+
"/convert-embed/",
|
|
30
|
+
files=files,
|
|
31
|
+
data={"output_file_name": "output"},
|
|
32
|
+
)
|
|
33
|
+
assert response.status_code == 400
|
|
34
|
+
assert "Maximum" in response.json()["detail"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import zipfile
|
|
4
|
+
|
|
5
|
+
from httpx import AsyncClient
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _make_embed_text(filename: str = "test.png") -> bytes:
|
|
10
|
+
buf = io.BytesIO()
|
|
11
|
+
Image.new("RGB", (10, 10), color="blue").save(buf, format="PNG")
|
|
12
|
+
encoded = base64.b64encode(buf.getvalue()).decode()
|
|
13
|
+
return f"Filename: test\n\nImage 1 ({filename}):\n{encoded}".encode()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def test_extract_images_returns_zip(client: AsyncClient) -> None:
|
|
17
|
+
response = await client.post(
|
|
18
|
+
"/extract-images/",
|
|
19
|
+
files=[("file", ("embed.txt", _make_embed_text(), "text/plain"))],
|
|
20
|
+
)
|
|
21
|
+
assert response.status_code == 200
|
|
22
|
+
assert response.headers["content-type"] == "application/zip"
|
|
23
|
+
zf = zipfile.ZipFile(io.BytesIO(response.content))
|
|
24
|
+
assert len(zf.namelist()) == 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def test_extract_no_images_returns_400(client: AsyncClient) -> None:
|
|
28
|
+
response = await client.post(
|
|
29
|
+
"/extract-images/",
|
|
30
|
+
files=[("file", ("empty.txt", b"no images here", "text/plain"))],
|
|
31
|
+
)
|
|
32
|
+
assert response.status_code == 400
|
|
33
|
+
assert "No images found" in response.json()["detail"]
|