crayonmlx 0.1.0a1__py3-none-any.whl
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.
- crayonmlx/__init__.py +3 -0
- crayonmlx/__main__.py +3 -0
- crayonmlx/app.py +134 -0
- crayonmlx/cli.py +12 -0
- crayonmlx/engines/__init__.py +1 -0
- crayonmlx/engines/base.py +17 -0
- crayonmlx/engines/mflux.py +91 -0
- crayonmlx/engines/process.py +59 -0
- crayonmlx/engines/registry.py +20 -0
- crayonmlx/engines/worker.py +137 -0
- crayonmlx/generation/__init__.py +1 -0
- crayonmlx/generation/queue.py +87 -0
- crayonmlx/models/__init__.py +1 -0
- crayonmlx/models/catalog.py +40 -0
- crayonmlx/models/downloads.py +63 -0
- crayonmlx/models/identity.py +114 -0
- crayonmlx/models/runtime.py +158 -0
- crayonmlx/paths.py +33 -0
- crayonmlx/presets/__init__.py +1 -0
- crayonmlx/presets/catalog.py +70 -0
- crayonmlx/presets/defaults.py +24 -0
- crayonmlx/presets/legacy_defaults.py +37 -0
- crayonmlx/presets/legacy_styles.py +50 -0
- crayonmlx/presets/library.py +56 -0
- crayonmlx/presets/repository.py +77 -0
- crayonmlx/presets/styles.py +28 -0
- crayonmlx/py.typed +0 -0
- crayonmlx/schemas.py +131 -0
- crayonmlx/storage/__init__.py +1 -0
- crayonmlx/storage/database.py +112 -0
- crayonmlx/storage/location.py +69 -0
- crayonmlx/storage/secrets.py +21 -0
- crayonmlx/web/__init__.py +1 -0
- crayonmlx/web/folders.py +86 -0
- crayonmlx/web/index.html +420 -0
- crayonmlx/web/library_routes.py +78 -0
- crayonmlx/web/model_routes.py +70 -0
- crayonmlx/web/routes.py +177 -0
- crayonmlx/web/static/api.js +53 -0
- crayonmlx/web/static/app.js +88 -0
- crayonmlx/web/static/forms.js +105 -0
- crayonmlx/web/static/generation.js +175 -0
- crayonmlx/web/static/library.js +305 -0
- crayonmlx/web/static/preservation.js +74 -0
- crayonmlx/web/static/preset-editor.js +260 -0
- crayonmlx/web/static/presets.js +210 -0
- crayonmlx/web/static/style.css +784 -0
- crayonmlx/web/storage_routes.py +24 -0
- crayonmlx-0.1.0a1.dist-info/METADATA +201 -0
- crayonmlx-0.1.0a1.dist-info/RECORD +52 -0
- crayonmlx-0.1.0a1.dist-info/WHEEL +4 -0
- crayonmlx-0.1.0a1.dist-info/entry_points.txt +3 -0
crayonmlx/__init__.py
ADDED
crayonmlx/__main__.py
ADDED
crayonmlx/app.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Application composition: construct services once and close workers on shutdown."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
10
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
13
|
+
|
|
14
|
+
from crayonmlx.engines.registry import default_engines
|
|
15
|
+
from crayonmlx.generation.queue import GenerationQueue
|
|
16
|
+
from crayonmlx.models.downloads import Downloads
|
|
17
|
+
from crayonmlx.models.runtime import ModelRuntime
|
|
18
|
+
from crayonmlx.paths import data_directory
|
|
19
|
+
from crayonmlx.presets.library import ConfigRepository, PromptRepository
|
|
20
|
+
from crayonmlx.presets.repository import PresetRepository
|
|
21
|
+
from crayonmlx.storage.database import Database
|
|
22
|
+
from crayonmlx.storage.secrets import TokenStore
|
|
23
|
+
from crayonmlx.web.folders import router as folders_router
|
|
24
|
+
from crayonmlx.web.library_routes import router as library_router
|
|
25
|
+
from crayonmlx.web.model_routes import router as model_router
|
|
26
|
+
from crayonmlx.web.routes import router
|
|
27
|
+
from crayonmlx.web.storage_routes import router as storage_router
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Services:
|
|
32
|
+
root: Path
|
|
33
|
+
database: Database
|
|
34
|
+
presets: PresetRepository
|
|
35
|
+
prompts: PromptRepository
|
|
36
|
+
configs: ConfigRepository
|
|
37
|
+
tokens: TokenStore
|
|
38
|
+
engines: dict
|
|
39
|
+
queue: GenerationQueue
|
|
40
|
+
downloads: Downloads
|
|
41
|
+
runtime: ModelRuntime
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_app(data_dir: Path | None = None, engines: dict | None = None) -> FastAPI:
|
|
45
|
+
@asynccontextmanager
|
|
46
|
+
async def lifespan(app):
|
|
47
|
+
root = (data_dir or data_directory()).resolve()
|
|
48
|
+
database = Database(root)
|
|
49
|
+
runtime = ModelRuntime(root / "runtime", database.settings)
|
|
50
|
+
registry = engines if engines is not None else default_engines(runtime)
|
|
51
|
+
tokens = TokenStore(root)
|
|
52
|
+
queue = GenerationQueue(database, registry)
|
|
53
|
+
downloads = Downloads(tokens)
|
|
54
|
+
app.state.services = Services(
|
|
55
|
+
root,
|
|
56
|
+
database,
|
|
57
|
+
PresetRepository(root),
|
|
58
|
+
PromptRepository(root),
|
|
59
|
+
ConfigRepository(root),
|
|
60
|
+
tokens,
|
|
61
|
+
registry,
|
|
62
|
+
queue,
|
|
63
|
+
downloads,
|
|
64
|
+
runtime,
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
yield
|
|
68
|
+
finally:
|
|
69
|
+
queue.close()
|
|
70
|
+
runtime.close()
|
|
71
|
+
downloads.close()
|
|
72
|
+
|
|
73
|
+
app = FastAPI(title="CrayonMLX", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
74
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["localhost", "127.0.0.1", "[::1]", "testserver"])
|
|
75
|
+
|
|
76
|
+
write_lock = asyncio.Lock()
|
|
77
|
+
app.state.pending_data_dir = None
|
|
78
|
+
|
|
79
|
+
@app.middleware("http")
|
|
80
|
+
async def local_requests(request: Request, call_next):
|
|
81
|
+
# Local disk operations must not be triggered by a form on a remote website.
|
|
82
|
+
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
83
|
+
origin = request.headers.get("origin")
|
|
84
|
+
if (origin and origin != str(request.base_url).rstrip("/")) or request.headers.get(
|
|
85
|
+
"sec-fetch-site"
|
|
86
|
+
) == "cross-site":
|
|
87
|
+
return JSONResponse(
|
|
88
|
+
{"detail": "Разрешены только запросы из локального интерфейса"}, status_code=403
|
|
89
|
+
)
|
|
90
|
+
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
91
|
+
async with write_lock:
|
|
92
|
+
if app.state.pending_data_dir:
|
|
93
|
+
return JSONResponse(
|
|
94
|
+
{
|
|
95
|
+
"detail": "Папка данных перенесена. Перезапустите CrayonMLX, чтобы продолжить работу."
|
|
96
|
+
},
|
|
97
|
+
status_code=409,
|
|
98
|
+
)
|
|
99
|
+
response = await call_next(request)
|
|
100
|
+
else:
|
|
101
|
+
response = await call_next(request)
|
|
102
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
103
|
+
response.headers["Content-Security-Policy"] = (
|
|
104
|
+
"default-src 'self'; img-src 'self' blob:; style-src 'self'; script-src 'self'; frame-ancestors 'none'"
|
|
105
|
+
)
|
|
106
|
+
return response
|
|
107
|
+
|
|
108
|
+
@app.exception_handler(ValueError)
|
|
109
|
+
async def invalid_value(request, error):
|
|
110
|
+
return JSONResponse({"detail": str(error)}, status_code=422)
|
|
111
|
+
|
|
112
|
+
@app.exception_handler(OSError)
|
|
113
|
+
async def filesystem_error(request, error):
|
|
114
|
+
logging.getLogger(__name__).exception("Filesystem operation failed")
|
|
115
|
+
return JSONResponse(
|
|
116
|
+
{
|
|
117
|
+
"detail": "Не удалось прочитать или записать файл. Проверьте путь, права доступа и свободное место."
|
|
118
|
+
},
|
|
119
|
+
status_code=400,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
web = Path(__file__).parent / "web"
|
|
123
|
+
app.include_router(router)
|
|
124
|
+
app.include_router(library_router)
|
|
125
|
+
app.include_router(folders_router)
|
|
126
|
+
app.include_router(model_router)
|
|
127
|
+
app.include_router(storage_router)
|
|
128
|
+
app.mount("/static", StaticFiles(directory=web / "static"), name="static")
|
|
129
|
+
|
|
130
|
+
@app.get("/")
|
|
131
|
+
def index():
|
|
132
|
+
return FileResponse(web / "index.html")
|
|
133
|
+
|
|
134
|
+
return app
|
crayonmlx/cli.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Local-only application entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
import uvicorn
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(description="CrayonMLX — локальная студия для рисунков")
|
|
10
|
+
parser.add_argument("--port", type=int, default=8787)
|
|
11
|
+
arguments = parser.parse_args()
|
|
12
|
+
uvicorn.run("crayonmlx.app:create_app", factory=True, host="127.0.0.1", port=arguments.port)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CrayonMLX engines components."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Render contract. Adapters render through the application-owned model runtime."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from crayonmlx.schemas import Preset
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RenderEngine(Protocol):
|
|
10
|
+
id: str
|
|
11
|
+
name: str
|
|
12
|
+
base_models: tuple[str, ...]
|
|
13
|
+
supports_strength: bool
|
|
14
|
+
|
|
15
|
+
def available(self) -> bool: ...
|
|
16
|
+
|
|
17
|
+
def render(self, preset: Preset, model: Path, source: Path, output: Path) -> dict | None: ...
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""MFLUX CLI adapters run in isolated processes so Metal memory is released per job."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from importlib.util import find_spec
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from crayonmlx.schemas import Preset
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MfluxEngine:
|
|
14
|
+
def __init__(self, runtime=None):
|
|
15
|
+
self.runtime = runtime
|
|
16
|
+
|
|
17
|
+
def available(self) -> bool:
|
|
18
|
+
return (
|
|
19
|
+
platform.system() == "Darwin" and platform.machine() == "arm64" and find_spec("mflux") is not None
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def command(self, preset: Preset, model: Path, source: Path, output: Path) -> list[str]:
|
|
23
|
+
parameters = preset.parameters
|
|
24
|
+
return [
|
|
25
|
+
sys.executable,
|
|
26
|
+
"-m",
|
|
27
|
+
self.module,
|
|
28
|
+
"--model",
|
|
29
|
+
str(model),
|
|
30
|
+
"--base-model",
|
|
31
|
+
preset.base_model,
|
|
32
|
+
"--prompt",
|
|
33
|
+
preset.blocks.compose(),
|
|
34
|
+
"--seed",
|
|
35
|
+
str(parameters.seed),
|
|
36
|
+
"--steps",
|
|
37
|
+
str(parameters.steps),
|
|
38
|
+
"--width",
|
|
39
|
+
str(parameters.width),
|
|
40
|
+
"--height",
|
|
41
|
+
str(parameters.height),
|
|
42
|
+
*(["--quantize", str(parameters.quantize)] if parameters.quantize is not None else []),
|
|
43
|
+
"--output",
|
|
44
|
+
str(output),
|
|
45
|
+
*self.image_arguments(preset, source),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def render(self, preset: Preset, model: Path, source: Path, output: Path) -> dict | None:
|
|
49
|
+
if not self.available():
|
|
50
|
+
raise RuntimeError("Нужен Mac Apple Silicon и MFLUX. Установите: uv sync или uv tool install .")
|
|
51
|
+
if self.runtime is not None:
|
|
52
|
+
return self.runtime.render(self, preset, model, source, output)
|
|
53
|
+
# Generation must never trigger implicit network access or ignore missing weights.
|
|
54
|
+
environment = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1"}
|
|
55
|
+
log = output.with_suffix(".log")
|
|
56
|
+
with log.open("w", encoding="utf-8") as stream:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
self.command(preset, model, source, output),
|
|
59
|
+
env=environment,
|
|
60
|
+
stdout=stream,
|
|
61
|
+
stderr=subprocess.STDOUT,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
if result.returncode or not output.is_file():
|
|
65
|
+
tail = log.read_text(encoding="utf-8", errors="replace")[-3000:]
|
|
66
|
+
raise RuntimeError(f"MFLUX не завершил генерацию. Проверьте полноту и тип модели.\n{tail}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FluxEngine(MfluxEngine):
|
|
70
|
+
id = "mflux-flux"
|
|
71
|
+
name = "MFLUX · FLUX.1 image-to-image"
|
|
72
|
+
module = "mflux.models.flux.cli.flux_generate"
|
|
73
|
+
base_models = ("dev", "schnell", "krea-dev")
|
|
74
|
+
supports_strength = True
|
|
75
|
+
|
|
76
|
+
def image_arguments(self, preset: Preset, source: Path) -> list[str]:
|
|
77
|
+
result = ["--image-path", str(source), "--image-strength", str(preset.parameters.image_strength)]
|
|
78
|
+
if preset.base_model != "schnell":
|
|
79
|
+
result += ["--guidance", str(preset.parameters.guidance)]
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class QwenEditEngine(MfluxEngine):
|
|
84
|
+
id = "mflux-qwen-edit"
|
|
85
|
+
name = "MFLUX · Qwen Image Edit"
|
|
86
|
+
module = "mflux.models.qwen.cli.qwen_image_edit_generate"
|
|
87
|
+
base_models = ("qwen-image-edit-2511", "qwen-image-edit")
|
|
88
|
+
supports_strength = False
|
|
89
|
+
|
|
90
|
+
def image_arguments(self, preset: Preset, source: Path) -> list[str]:
|
|
91
|
+
return ["--image-paths", str(source), "--guidance", str(preset.parameters.guidance)]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Process transport, independent of model lifecycle and GPU imports."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkerProcess:
|
|
13
|
+
def __init__(self, root: Path):
|
|
14
|
+
self.root = root / uuid4().hex
|
|
15
|
+
self.root.mkdir(parents=True)
|
|
16
|
+
self.log = (self.root / "worker.log").open("w", encoding="utf-8")
|
|
17
|
+
self.process = subprocess.Popen(
|
|
18
|
+
[sys.executable, "-m", "crayonmlx.engines.worker", str(self.root)],
|
|
19
|
+
stdin=subprocess.PIPE,
|
|
20
|
+
stdout=self.log,
|
|
21
|
+
stderr=subprocess.STDOUT,
|
|
22
|
+
text=True,
|
|
23
|
+
env={**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1"},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def request(self, payload):
|
|
27
|
+
identifier = uuid4().hex
|
|
28
|
+
response = self.root / f"{identifier}.json"
|
|
29
|
+
self.process.stdin.write(json.dumps({**payload, "request_id": identifier}) + "\n")
|
|
30
|
+
self.process.stdin.flush()
|
|
31
|
+
while not response.exists():
|
|
32
|
+
if self.process.poll() is not None:
|
|
33
|
+
tail = (self.root / "worker.log").read_text(encoding="utf-8", errors="replace")[-2000:]
|
|
34
|
+
raise RuntimeError(f"Процесс MLX остановился.\n{tail}")
|
|
35
|
+
time.sleep(0.1)
|
|
36
|
+
result = json.loads(response.read_text())
|
|
37
|
+
response.unlink()
|
|
38
|
+
if not result["ok"]:
|
|
39
|
+
raise RuntimeError(result["error"])
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
def snapshot(self):
|
|
43
|
+
path = self.root / "status.json"
|
|
44
|
+
return json.loads(path.read_text()) if path.exists() else {}
|
|
45
|
+
|
|
46
|
+
def alive(self):
|
|
47
|
+
return self.process.poll() is None
|
|
48
|
+
|
|
49
|
+
def close(self):
|
|
50
|
+
if self.process.poll() is None:
|
|
51
|
+
self.process.terminate()
|
|
52
|
+
try:
|
|
53
|
+
self.process.wait(timeout=10)
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
self.process.kill()
|
|
56
|
+
self.process.wait()
|
|
57
|
+
if self.process.stdin:
|
|
58
|
+
self.process.stdin.close()
|
|
59
|
+
self.log.close()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Explicit registry: new engines implement the contract without changing the queue."""
|
|
2
|
+
|
|
3
|
+
from crayonmlx.engines.base import RenderEngine
|
|
4
|
+
from crayonmlx.engines.mflux import FluxEngine, QwenEditEngine
|
|
5
|
+
from crayonmlx.schemas import Preset
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def default_engines(runtime=None) -> dict[str, RenderEngine]:
|
|
9
|
+
return {engine.id: engine for engine in (FluxEngine(runtime), QwenEditEngine(runtime))}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_engine(engines: dict[str, RenderEngine], preset: Preset) -> RenderEngine:
|
|
13
|
+
engine = engines.get(preset.engine)
|
|
14
|
+
if engine is None:
|
|
15
|
+
raise ValueError("Неизвестный движок")
|
|
16
|
+
if preset.base_model not in engine.base_models:
|
|
17
|
+
raise ValueError("Базовая модель несовместима с выбранным движком")
|
|
18
|
+
if not preset.blocks.compose():
|
|
19
|
+
raise ValueError("Заполните хотя бы один блок промпта")
|
|
20
|
+
return engine
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Persistent MLX process. Commands arrive on stdin; atomic files carry responses/status."""
|
|
2
|
+
|
|
3
|
+
import gc
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import traceback
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from crayonmlx.schemas import Preset
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Telemetry:
|
|
15
|
+
def __init__(self, root, mx):
|
|
16
|
+
self.root, self.mx = root, mx
|
|
17
|
+
self.lock = threading.Lock()
|
|
18
|
+
self.state = {"phase": "starting", "step": 0, "total_steps": None}
|
|
19
|
+
self.started = None
|
|
20
|
+
self.stopped = threading.Event()
|
|
21
|
+
self.thread = threading.Thread(target=self._sample, daemon=True)
|
|
22
|
+
self.thread.start()
|
|
23
|
+
|
|
24
|
+
def update(self, **values):
|
|
25
|
+
with self.lock:
|
|
26
|
+
self.state.update(values)
|
|
27
|
+
self._write()
|
|
28
|
+
|
|
29
|
+
def _write(self):
|
|
30
|
+
state = {
|
|
31
|
+
**self.state,
|
|
32
|
+
"memory_active_bytes": self.mx.get_active_memory(),
|
|
33
|
+
"memory_cache_bytes": self.mx.get_cache_memory(),
|
|
34
|
+
"memory_peak_bytes": self.mx.get_peak_memory(),
|
|
35
|
+
"elapsed_seconds": round(time.monotonic() - self.started, 1) if self.started else 0,
|
|
36
|
+
}
|
|
37
|
+
temporary = self.root / "status.tmp"
|
|
38
|
+
temporary.write_text(json.dumps(state), encoding="utf-8")
|
|
39
|
+
temporary.replace(self.root / "status.json")
|
|
40
|
+
|
|
41
|
+
def _sample(self):
|
|
42
|
+
while not self.stopped.wait(1):
|
|
43
|
+
with self.lock:
|
|
44
|
+
self._write()
|
|
45
|
+
|
|
46
|
+
def call_before_loop(self, config, **kwargs):
|
|
47
|
+
self.update(
|
|
48
|
+
phase="denoising", step=0, total_steps=max(0, config.num_inference_steps - config.init_time_step)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def call_in_loop(self, t, config, latents, **kwargs):
|
|
52
|
+
self.mx.eval(latents) # Report completed work, not just lazily scheduled GPU operations.
|
|
53
|
+
self.update(phase="denoising", step=t - config.init_time_step + 1)
|
|
54
|
+
|
|
55
|
+
def call_after_loop(self, **kwargs):
|
|
56
|
+
self.update(phase="decoding")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_model(payload):
|
|
60
|
+
from mflux.models.common.config import ModelConfig
|
|
61
|
+
|
|
62
|
+
config = ModelConfig.from_name(model_name=payload["base_model"])
|
|
63
|
+
if payload["engine"] == "mflux-flux":
|
|
64
|
+
from mflux.models.flux.variants.txt2img.flux import Flux1
|
|
65
|
+
|
|
66
|
+
model_class = Flux1
|
|
67
|
+
elif payload["engine"] == "mflux-qwen-edit":
|
|
68
|
+
from mflux.models.qwen.variants.edit.qwen_image_edit import QwenImageEdit
|
|
69
|
+
|
|
70
|
+
model_class = QwenImageEdit
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError("Unsupported MLX adapter")
|
|
73
|
+
return model_class(model_config=config, quantize=payload["quantize"], model_path=payload["model_path"])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render(model, payload):
|
|
77
|
+
preset = Preset.model_validate(payload["preset"])
|
|
78
|
+
params = preset.parameters
|
|
79
|
+
arguments = dict(
|
|
80
|
+
seed=params.seed,
|
|
81
|
+
prompt=preset.blocks.compose(),
|
|
82
|
+
width=params.width,
|
|
83
|
+
height=params.height,
|
|
84
|
+
num_inference_steps=params.steps,
|
|
85
|
+
guidance=params.guidance,
|
|
86
|
+
)
|
|
87
|
+
if preset.engine == "mflux-flux":
|
|
88
|
+
arguments.update(image_path=payload["source"], image_strength=params.image_strength)
|
|
89
|
+
else:
|
|
90
|
+
arguments.update(image_paths=[payload["source"]], image_path=payload["source"])
|
|
91
|
+
image = model.generate_image(**arguments)
|
|
92
|
+
image.save(path=payload["output"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main():
|
|
96
|
+
import mlx.core as mx
|
|
97
|
+
|
|
98
|
+
root = Path(sys.argv[1])
|
|
99
|
+
telemetry = Telemetry(root, mx)
|
|
100
|
+
model = None
|
|
101
|
+
try:
|
|
102
|
+
for line in sys.stdin:
|
|
103
|
+
payload = json.loads(line)
|
|
104
|
+
response = root / f"{payload['request_id']}.json"
|
|
105
|
+
try:
|
|
106
|
+
if payload["action"] == "load":
|
|
107
|
+
telemetry.update(phase="loading", step=0, total_steps=None)
|
|
108
|
+
model = load_model(payload)
|
|
109
|
+
mx.eval(model.parameters())
|
|
110
|
+
model.callbacks.register(telemetry)
|
|
111
|
+
elif payload["action"] == "render":
|
|
112
|
+
if model is None:
|
|
113
|
+
raise RuntimeError("Model is not loaded")
|
|
114
|
+
telemetry.started = time.monotonic()
|
|
115
|
+
mx.reset_peak_memory()
|
|
116
|
+
telemetry.update(phase="preparing", step=0, total_steps=None)
|
|
117
|
+
render(model, payload)
|
|
118
|
+
gc.collect()
|
|
119
|
+
mx.clear_cache()
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError("Unknown command")
|
|
122
|
+
telemetry.update(phase="ready", effective_quantize=getattr(model, "bits", None))
|
|
123
|
+
result = {"ok": True}
|
|
124
|
+
except Exception as error:
|
|
125
|
+
traceback.print_exc()
|
|
126
|
+
telemetry.update(phase="error")
|
|
127
|
+
result = {"ok": False, "error": str(error)}
|
|
128
|
+
temporary = response.with_suffix(".tmp")
|
|
129
|
+
temporary.write_text(json.dumps(result), encoding="utf-8")
|
|
130
|
+
temporary.replace(response)
|
|
131
|
+
finally:
|
|
132
|
+
telemetry.stopped.set()
|
|
133
|
+
telemetry.thread.join(timeout=2)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CrayonMLX generation components."""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""One render worker, durable job snapshots and immutable per-job file locations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from secrets import randbelow
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from crayonmlx.engines.registry import validate_engine
|
|
12
|
+
from crayonmlx.models.catalog import resolve_model
|
|
13
|
+
from crayonmlx.models.identity import identify_model
|
|
14
|
+
from crayonmlx.schemas import GenerationRequest
|
|
15
|
+
from crayonmlx.storage.database import Database
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def now() -> str:
|
|
21
|
+
return datetime.now(timezone.utc).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GenerationQueue:
|
|
25
|
+
def __init__(self, database: Database, engines: dict):
|
|
26
|
+
self.database = database
|
|
27
|
+
self.engines = engines
|
|
28
|
+
self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="crayonmlx-render")
|
|
29
|
+
|
|
30
|
+
def submit(self, request: GenerationRequest, image) -> dict:
|
|
31
|
+
request = request.model_copy(deep=True)
|
|
32
|
+
settings = self.database.settings()
|
|
33
|
+
model = resolve_model(Path(settings.models_dir), request.model)
|
|
34
|
+
if not self.database.model_enabled(str(model)):
|
|
35
|
+
raise ValueError("Модель выключена. Включите её в разделе «Модели».")
|
|
36
|
+
identity = identify_model(model)
|
|
37
|
+
request.preset.engine = identity.engine
|
|
38
|
+
request.preset.base_model = identity.base_model
|
|
39
|
+
engine = validate_engine(self.engines, request.preset)
|
|
40
|
+
if request.preset.parameters.seed == -1:
|
|
41
|
+
request.preset.parameters.seed = randbelow(2**32)
|
|
42
|
+
identifier = uuid4().hex
|
|
43
|
+
directory = Path(settings.output_dir) / identifier
|
|
44
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
45
|
+
source, output = directory / "source.png", directory / "result.png"
|
|
46
|
+
image.save(source, format="PNG")
|
|
47
|
+
snapshot = request.model_dump()
|
|
48
|
+
snapshot["model_path"] = str(model)
|
|
49
|
+
snapshot["prompt"] = request.preset.blocks.compose()
|
|
50
|
+
metadata = json.dumps(snapshot, ensure_ascii=False, indent=2)
|
|
51
|
+
(directory / "generation.json").write_text(metadata, encoding="utf-8")
|
|
52
|
+
self.database.add_job(
|
|
53
|
+
dict(
|
|
54
|
+
id=identifier,
|
|
55
|
+
created_at=now(),
|
|
56
|
+
status="queued",
|
|
57
|
+
request=metadata,
|
|
58
|
+
source=str(source),
|
|
59
|
+
output=str(output),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
self.executor.submit(self._run, identifier, engine, request.preset, model, source, output)
|
|
63
|
+
return self.database.job(identifier)
|
|
64
|
+
|
|
65
|
+
def _run(self, identifier, engine, preset, model, source, output):
|
|
66
|
+
self.database.update_job(identifier, status="running", started_at=now())
|
|
67
|
+
try:
|
|
68
|
+
if not self.database.model_enabled(str(model)):
|
|
69
|
+
raise ValueError("Модель была выключена до начала задания")
|
|
70
|
+
execution = engine.render(preset, model, source, output) or {}
|
|
71
|
+
if not output.is_file():
|
|
72
|
+
raise RuntimeError("Движок не сохранил результат")
|
|
73
|
+
metadata_path = output.with_name("generation.json")
|
|
74
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
75
|
+
metadata["execution"] = execution
|
|
76
|
+
temporary = metadata_path.with_suffix(".tmp")
|
|
77
|
+
temporary.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
78
|
+
temporary.replace(metadata_path)
|
|
79
|
+
self.database.update_job(
|
|
80
|
+
identifier, status="completed", finished_at=now(), execution=json.dumps(execution)
|
|
81
|
+
)
|
|
82
|
+
except Exception as error:
|
|
83
|
+
logger.exception("Render failed: %s", identifier)
|
|
84
|
+
self.database.update_job(identifier, status="failed", error=str(error), finished_at=now())
|
|
85
|
+
|
|
86
|
+
def close(self):
|
|
87
|
+
self.executor.shutdown(wait=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CrayonMLX models components."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Discover manually downloaded Diffusers or MFLUX checkpoint directories."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from crayonmlx.models.identity import model_description
|
|
6
|
+
from crayonmlx.paths import child_path
|
|
7
|
+
|
|
8
|
+
MARKERS = ("model_index.json", "config.json", "transformer", "vae", "text_encoder", "mflux_model.json")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_checkpoint(path: Path) -> bool:
|
|
12
|
+
return path.is_dir() and any((path / marker).exists() for marker in MARKERS)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_models(root: Path) -> list[dict]:
|
|
16
|
+
if not root.is_dir():
|
|
17
|
+
return []
|
|
18
|
+
# A directory may itself be a checkpoint, or contain named checkpoints.
|
|
19
|
+
paths = [root] if is_checkpoint(root) else sorted(root.iterdir())
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
"id": "." if path == root else path.name,
|
|
23
|
+
"name": path.name,
|
|
24
|
+
"path": str(path),
|
|
25
|
+
**model_description(path),
|
|
26
|
+
}
|
|
27
|
+
for path in paths
|
|
28
|
+
if not path.name.startswith(".")
|
|
29
|
+
and is_checkpoint(path)
|
|
30
|
+
and path.resolve().is_relative_to(root.resolve())
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_model(root: Path, identifier: str) -> Path:
|
|
35
|
+
path = root.resolve() if identifier == "." else child_path(root, identifier)
|
|
36
|
+
if not is_checkpoint(path):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Не найдена локальная папка модели. Нужен полный checkpoint, а не отдельный файл весов."
|
|
39
|
+
)
|
|
40
|
+
return path
|