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.
Files changed (52) hide show
  1. crayonmlx/__init__.py +3 -0
  2. crayonmlx/__main__.py +3 -0
  3. crayonmlx/app.py +134 -0
  4. crayonmlx/cli.py +12 -0
  5. crayonmlx/engines/__init__.py +1 -0
  6. crayonmlx/engines/base.py +17 -0
  7. crayonmlx/engines/mflux.py +91 -0
  8. crayonmlx/engines/process.py +59 -0
  9. crayonmlx/engines/registry.py +20 -0
  10. crayonmlx/engines/worker.py +137 -0
  11. crayonmlx/generation/__init__.py +1 -0
  12. crayonmlx/generation/queue.py +87 -0
  13. crayonmlx/models/__init__.py +1 -0
  14. crayonmlx/models/catalog.py +40 -0
  15. crayonmlx/models/downloads.py +63 -0
  16. crayonmlx/models/identity.py +114 -0
  17. crayonmlx/models/runtime.py +158 -0
  18. crayonmlx/paths.py +33 -0
  19. crayonmlx/presets/__init__.py +1 -0
  20. crayonmlx/presets/catalog.py +70 -0
  21. crayonmlx/presets/defaults.py +24 -0
  22. crayonmlx/presets/legacy_defaults.py +37 -0
  23. crayonmlx/presets/legacy_styles.py +50 -0
  24. crayonmlx/presets/library.py +56 -0
  25. crayonmlx/presets/repository.py +77 -0
  26. crayonmlx/presets/styles.py +28 -0
  27. crayonmlx/py.typed +0 -0
  28. crayonmlx/schemas.py +131 -0
  29. crayonmlx/storage/__init__.py +1 -0
  30. crayonmlx/storage/database.py +112 -0
  31. crayonmlx/storage/location.py +69 -0
  32. crayonmlx/storage/secrets.py +21 -0
  33. crayonmlx/web/__init__.py +1 -0
  34. crayonmlx/web/folders.py +86 -0
  35. crayonmlx/web/index.html +420 -0
  36. crayonmlx/web/library_routes.py +78 -0
  37. crayonmlx/web/model_routes.py +70 -0
  38. crayonmlx/web/routes.py +177 -0
  39. crayonmlx/web/static/api.js +53 -0
  40. crayonmlx/web/static/app.js +88 -0
  41. crayonmlx/web/static/forms.js +105 -0
  42. crayonmlx/web/static/generation.js +175 -0
  43. crayonmlx/web/static/library.js +305 -0
  44. crayonmlx/web/static/preservation.js +74 -0
  45. crayonmlx/web/static/preset-editor.js +260 -0
  46. crayonmlx/web/static/presets.js +210 -0
  47. crayonmlx/web/static/style.css +784 -0
  48. crayonmlx/web/storage_routes.py +24 -0
  49. crayonmlx-0.1.0a1.dist-info/METADATA +201 -0
  50. crayonmlx-0.1.0a1.dist-info/RECORD +52 -0
  51. crayonmlx-0.1.0a1.dist-info/WHEEL +4 -0
  52. crayonmlx-0.1.0a1.dist-info/entry_points.txt +3 -0
crayonmlx/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CrayonMLX — a local drawing transformation studio for Apple Silicon."""
2
+
3
+ __version__ = "0.1.0a1"
crayonmlx/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from crayonmlx.cli import main
2
+
3
+ main()
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