File size: 13,343 Bytes
cb617a4
 
 
 
 
5fff6c4
cb617a4
5fff6c4
cb617a4
 
 
 
 
 
 
 
b5e5a50
5fff6c4
b5e5a50
5fff6c4
 
 
cb617a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fff6c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5e5a50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fff6c4
 
 
 
 
 
b5e5a50
 
 
 
 
 
cb617a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fff6c4
b5e5a50
5fff6c4
 
cb617a4
 
 
 
 
5fff6c4
b5e5a50
 
 
 
 
cb617a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fff6c4
 
 
 
 
 
 
 
 
cb617a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fff6c4
cb617a4
 
 
 
 
 
 
 
 
 
 
 
 
5fff6c4
 
 
 
 
 
 
 
 
cb617a4
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
"""Portable settings and PNG metadata helpers for the Krea 2 Space."""

from __future__ import annotations

import json
import hashlib
import math
import pathlib
import re
from pathlib import Path
from typing import Any

from PIL import Image, PngImagePlugin


APP_ID = "krea-2-turbo-i2i"
PROFILE_SCHEMA_VERSION = 3
CUSTOM_LORA_EXTENSIONS = {".safetensors", ".pt", ".ckpt", ".bin"}
CUSTOM_BASE_MODEL_EXTENSIONS = {".safetensors", ".pt", ".ckpt", ".bin"}
_HF_REPO_RE = re.compile(
    r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$"
)


def _finite_number(value: Any, field: str) -> float:
    try:
        number = float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{field} must be a number") from exc
    if not math.isfinite(number):
        raise ValueError(f"{field} must be finite")
    return number


def _text(value: Any, field: str, limit: int = 8192) -> str:
    result = "" if value is None else str(value)
    if "\x00" in result or len(result) > limit:
        raise ValueError(f"{field} is invalid or too long")
    return result.strip()


def _finite_number(value: Any, field: str) -> float:
    try:
        number = float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{field} must be a number") from exc
    if not math.isfinite(number):
        raise ValueError(f"{field} must be finite")
    return number


def validate_custom_lora(row: Any) -> dict[str, Any]:
    """Validate and normalize one custom Hugging Face LoRA row."""
    if isinstance(row, dict):
        repo_id = row.get("repo_id", "")
        filename = row.get("filename", row.get("file", ""))
        revision = row.get("revision", "")
        weight = row.get("weight", 0.0)
    elif isinstance(row, (list, tuple)):
        values = list(row) + [""] * 4
        repo_id, filename, revision, weight = values[:4]
    else:
        raise ValueError("custom LoRA rows must be objects or four-column arrays")

    repo_id = _text(repo_id, "custom LoRA repository", 193)
    if not _HF_REPO_RE.fullmatch(repo_id):
        raise ValueError(
            f"invalid Hugging Face repository ID {repo_id!r}; expected namespace/name"
        )

    filename = _text(filename, "custom LoRA file", 512).replace("\\", "/")
    path = pathlib.PurePosixPath(filename)
    if (
        not filename
        or path.is_absolute()
        or any(part in {"", ".", ".."} for part in path.parts)
        or path.suffix.lower() not in CUSTOM_LORA_EXTENSIONS
    ):
        allowed = ", ".join(sorted(CUSTOM_LORA_EXTENSIONS))
        raise ValueError(f"custom LoRA file must be relative and end in {allowed}")

    revision = _text(revision, "custom LoRA revision", 256)
    if any(ord(char) < 32 for char in revision):
        raise ValueError("custom LoRA revision contains control characters")

    weight_number = _finite_number(weight or 0.0, "custom LoRA weight")
    if weight_number < -3.0 or weight_number > 3.0:
        raise ValueError("custom LoRA weight must be between -3 and 3")

    return {
        "repo_id": repo_id,
        "filename": filename,
        "revision": revision,
        "weight": round(weight_number, 6),
    }


def normalize_custom_loras(rows: Any) -> tuple[list[dict[str, Any]], list[str]]:
    """Normalize non-empty custom rows and collect row errors as warnings."""
    if rows is None:
        return [], []
    if isinstance(rows, dict):
        rows = [rows]
    normalized: list[dict[str, Any]] = []
    warnings: list[str] = []
    for index, row in enumerate(rows):
        if row is None or row == [] or row == {}:
            continue
        if isinstance(row, (list, tuple)):
            values = list(row) + [""] * 4
            if not any(str(value).strip() for value in values[:3]) and not values[3]:
                continue
        try:
            normalized.append(validate_custom_lora(row))
        except ValueError as exc:
            warnings.append(f"custom LoRA row {index + 1}: {exc}")
    return normalized, warnings


def validate_custom_base_model(value: Any) -> dict[str, str]:
    """Validate one custom Hugging Face diffusion-model reference."""
    if isinstance(value, dict):
        repo_id = value.get("repo_id", "")
        filename = value.get("filename", value.get("file", ""))
        revision = value.get("revision", "")
    elif isinstance(value, (list, tuple)):
        values = list(value) + [""] * 3
        repo_id, filename, revision = values[:3]
    else:
        raise ValueError("custom base model must be an object or three-column array")

    repo_id = _text(repo_id, "custom base-model repository", 193)
    if not _HF_REPO_RE.fullmatch(repo_id):
        raise ValueError(
            f"invalid Hugging Face repository ID {repo_id!r}; expected namespace/name"
        )

    filename = _text(filename, "custom base-model file", 512).replace("\\", "/")
    path = pathlib.PurePosixPath(filename)
    if (
        not filename
        or path.is_absolute()
        or any(part in {"", ".", ".."} for part in path.parts)
        or path.suffix.lower() not in CUSTOM_BASE_MODEL_EXTENSIONS
    ):
        allowed = ", ".join(sorted(CUSTOM_BASE_MODEL_EXTENSIONS))
        raise ValueError(f"custom base-model file must be relative and end in {allowed}")

    revision = _text(revision, "custom base-model revision", 256)
    if any(ord(char) < 32 for char in revision):
        raise ValueError("custom base-model revision contains control characters")
    return {"repo_id": repo_id, "filename": filename, "revision": revision}


def stable_custom_lora_namespace(repo_id: str, filename: str, revision: str = "") -> str:
    """Return a filesystem-safe stable namespace for one remote LoRA."""
    identity = "\x00".join((repo_id, revision, filename)).encode("utf-8")
    return hashlib.sha256(identity).hexdigest()[:20]


def stable_custom_base_model_namespace(repo_id: str, filename: str, revision: str = "") -> str:
    """Return a filesystem-safe stable namespace for one remote base model."""
    identity = "\x00".join((repo_id, revision, filename)).encode("utf-8")
    return hashlib.sha256(identity).hexdigest()[:20]


def build_settings(

    *,

    mode: str,

    prompt: str,

    edit_prompt: str,

    width: int,

    height: int,

    target_megapixels: float,

    grounding_px: int,

    ref_boost: float,

    ref_boost_a: float,

    steps: int,

    cfg: float,

    sampler_name: str,

    scheduler: str,

    seed: int,

    randomize_seed: bool,

    gen_budget: float,

    effective_seed: int | None = None,

    base_model: str = "",

    custom_base_model: dict[str, Any] | None = None,

    catalog_loras: list[dict[str, Any]] | None = None,

    custom_loras: list[dict[str, Any]] | None = None,

) -> dict[str, Any]:
    """Build the canonical profile embedded in generated PNG files."""
    return {
        "app": APP_ID,
        "schema_version": PROFILE_SCHEMA_VERSION,
        "base_model": _text(base_model, "base model", 256),
        "custom_base_model": (
            validate_custom_base_model(custom_base_model)
            if custom_base_model
            else None
        ),
        "mode": _text(mode, "mode", 32),
        "prompt": _text(prompt, "prompt"),
        "edit_prompt": _text(edit_prompt, "edit prompt"),
        "width": int(width),
        "height": int(height),
        "target_megapixels": float(target_megapixels),
        "grounding_px": int(grounding_px),
        "ref_boost": float(ref_boost),
        "ref_boost_a": float(ref_boost_a),
        "steps": int(steps),
        "cfg": float(cfg),
        "sampler_name": _text(sampler_name, "sampler", 64),
        "scheduler": _text(scheduler, "scheduler", 64),
        "seed": int(seed),
        "effective_seed": None if effective_seed is None else int(effective_seed),
        "randomize_seed": bool(randomize_seed),
        "gen_budget": float(gen_budget),
        "catalog_loras": [
            {
                "hf_filename": _text(item.get("hf_filename", ""), "catalog LoRA filename", 512),
                "weight": float(item.get("weight", 0.0)),
            }
            for item in (catalog_loras or [])
            if item.get("hf_filename") and abs(float(item.get("weight", 0.0))) > 1e-6
        ],
        "custom_loras": [validate_custom_lora(item) for item in (custom_loras or [])],
    }


def parse_settings_text(text: Any) -> tuple[dict[str, Any], list[str]]:
    """Parse a Krea JSON profile or a compact A1111-style parameter string."""
    value = "" if text is None else str(text).strip()
    if not value:
        return {}, ["settings text is empty"]
    try:
        decoded = json.loads(value)
    except json.JSONDecodeError:
        lines = value.splitlines()
        result: dict[str, Any] = {}
        steps = re.search(r"Steps:\s*(\d+)", value, re.I)
        cfg = re.search(r"CFG scale:\s*([\d.]+)", value, re.I)
        sampler = re.search(r"Sampler:\s*([^,\n]+)", value, re.I)
        seed = re.search(r"Seed:\s*(\d+)", value, re.I)
        size = re.search(r"Size:\s*(\d+)\s*[xX×]\s*(\d+)", value, re.I)
        negative_line = next(
            (index for index, line in enumerate(lines) if line.lower().startswith("negative prompt:")),
            None,
        )
        if negative_line is not None:
            result["prompt"] = "\n".join(lines[:negative_line]).strip()
        else:
            result["prompt"] = lines[0].strip() if lines else ""
        if steps:
            result["steps"] = int(steps.group(1))
        if cfg:
            result["cfg"] = float(cfg.group(1))
        if sampler:
            result["sampler_name"] = sampler.group(1).strip()
        if seed:
            result["effective_seed"] = int(seed.group(1))
        if size:
            result["width"] = int(size.group(1))
            result["height"] = int(size.group(2))
        return result, []
    if not isinstance(decoded, dict):
        return {}, ["settings JSON must be an object"]
    if isinstance(decoded.get("krea_settings"), str):
        try:
            decoded = json.loads(decoded["krea_settings"])
        except json.JSONDecodeError:
            return {}, ["krea_settings metadata is not valid JSON"]
    return decoded, []


def extract_image_settings(path: str | Path) -> tuple[dict[str, Any], list[str]]:
    """Extract Krea settings or common parameter text from an image."""
    try:
        with Image.open(path) as image:
            metadata = dict(image.info)
    except Exception as exc:
        return {}, [f"could not read image metadata: {exc}"]
    for key in ("krea_settings", "parameters", "prompt"):
        raw = metadata.get(key)
        if isinstance(raw, bytes):
            raw = raw.decode("utf-8", errors="ignore")
        if isinstance(raw, str) and raw.strip():
            data, warnings = parse_settings_text(raw)
            if data:
                return data, warnings
    return {}, ["image contains no recognized Krea settings"]


def build_parameters_text(settings: dict[str, Any]) -> str:
    """Build a readable generation summary for image viewers."""
    prompt = settings.get("edit_prompt") or settings.get("prompt", "")
    parts = [
        f"Base model: {settings.get('base_model', '')}",
        f"Steps: {settings.get('steps')}",
        f"CFG scale: {settings.get('cfg')}",
        f"Sampler: {settings.get('sampler_name')}",
        f"Schedule type: {settings.get('scheduler')}",
        f"Seed: {settings.get('effective_seed', settings.get('seed'))}",
        f"Size: {settings.get('width')}x{settings.get('height')}",
    ]
    if settings.get("mode") == "edit":
        parts.extend([
            f"Grounding: {settings.get('grounding_px')}px",
            f"Reference strength: {settings.get('ref_boost')}",
            f"Second reference strength: {settings.get('ref_boost_a')}",
        ])
    lora_tags = []
    for item in settings.get("catalog_loras", []):
        name = pathlib.PurePosixPath(str(item.get("hf_filename", ""))).stem
        lora_tags.append(f"{name}:{float(item.get('weight', 0.0)):g}")
    for item in settings.get("custom_loras", []):
        name = pathlib.PurePosixPath(str(item.get("filename", ""))).stem
        lora_tags.append(f"{name}:{float(item.get('weight', 0.0)):g}")
    if lora_tags:
        parts.append("LoRAs: " + ", ".join(lora_tags))
    return f"{prompt}\n" + ", ".join(parts)


def write_png_metadata(source: str | Path, destination: str | Path, settings: dict[str, Any]) -> None:
    """Copy an image while adding canonical and readable Krea metadata."""
    with Image.open(source) as image:
        output = image.copy()
        info = PngImagePlugin.PngInfo()
        for key, value in image.info.items():
            if key not in {"krea_settings", "parameters"} and isinstance(value, str):
                info.add_text(key, value)
        info.add_text("krea_settings", json.dumps(settings, sort_keys=True))
        info.add_text("parameters", build_parameters_text(settings))
        output.save(destination, format="PNG", pnginfo=info)