setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -1769,14 +1769,38 @@ class CurvesDialogPro(QDialog):
1769
1769
  name, ok = QInputDialog.getText(self, self.tr("Save Curves Preset"), self.tr("Preset name:"))
1770
1770
  if not ok or not name.strip():
1771
1771
  return
1772
- pts_norm = self._collect_points_norm_from_editor()
1773
- mode = self._current_mode()
1774
- if save_custom_preset(name.strip(), mode, pts_norm):
1775
- self._set_status(self.tr("Saved preset “{0}”.").format(name.strip()))
1772
+ name = name.strip()
1773
+
1774
+ # 0) flush active editor -> store (CRITICAL)
1775
+ try:
1776
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
1777
+ except Exception:
1778
+ pass
1779
+
1780
+ # 1) build a full multi-curve payload
1781
+ modes = {}
1782
+ for k, pts in self._curves_store.items():
1783
+ if not isinstance(pts, (list, tuple)) or len(pts) < 2:
1784
+ continue
1785
+ # ensure floats (QSettings/JSON safety)
1786
+ modes[k] = [(float(x), float(y)) for (x, y) in pts]
1787
+
1788
+ preset = {
1789
+ "name": name,
1790
+ "version": 2,
1791
+ "kind": "curves_multi",
1792
+ "active": self._current_mode_key, # "K", "R", ...
1793
+ "modes": modes,
1794
+ }
1795
+
1796
+ # 2) save via your existing persistence
1797
+ if save_custom_preset(name, preset): # <-- change signature (see section 3)
1798
+ self._set_status(self.tr("Saved preset “{0}”.").format(name))
1776
1799
  self._rebuild_presets_menu()
1777
1800
  else:
1778
1801
  QMessageBox.warning(self, self.tr("Save failed"), self.tr("Could not save preset."))
1779
1802
 
1803
+
1780
1804
  def _rebuild_presets_menu(self):
1781
1805
  m = QMenu(self)
1782
1806
  # Built-in shapes under K (Brightness)
@@ -2400,43 +2424,69 @@ class CurvesDialogPro(QDialog):
2400
2424
  def _apply_preset_dict(self, preset: dict):
2401
2425
  preset = preset or {}
2402
2426
 
2403
- # 1) set mode radio
2427
+ # -------- MULTI-CURVE (v2) --------
2428
+ if preset.get("kind") == "curves_multi" or ("modes" in preset and isinstance(preset.get("modes"), dict)):
2429
+ modes = preset.get("modes", {}) or {}
2430
+
2431
+ # 0) load all curves into store (fill missing keys with linear)
2432
+ for k in self._curves_store.keys():
2433
+ pts = modes.get(k)
2434
+ if isinstance(pts, (list, tuple)) and len(pts) >= 2:
2435
+ self._curves_store[k] = [(float(x), float(y)) for (x, y) in pts]
2436
+ else:
2437
+ self._curves_store[k] = [(0.0, 0.0), (1.0, 1.0)]
2438
+
2439
+ # 1) choose active key (default to K)
2440
+ active = str(preset.get("active") or "K")
2441
+ if active not in self._curves_store:
2442
+ active = "K"
2443
+ self._current_mode_key = active
2444
+
2445
+ # 2) set radio button that corresponds to active key
2446
+ # map internal key -> radio label
2447
+ key_to_label = {v: k for (k, v) in self._mode_key_map.items()} # "K"->"K (Brightness)"
2448
+ want_label = key_to_label.get(active, "K (Brightness)")
2449
+ for b in self.mode_group.buttons():
2450
+ if b.text() == want_label:
2451
+ b.setChecked(True)
2452
+ break
2453
+
2454
+ # 3) push active curve into editor
2455
+ self._editor_set_from_norm(self._curves_store[active])
2456
+
2457
+ # 4) refresh overlays + preview
2458
+ self._refresh_overlays()
2459
+ self._quick_preview()
2460
+
2461
+ self._set_status(self.tr("Preset: {0} [multi]").format(preset.get("name", self.tr("(built-in)"))))
2462
+ return
2463
+
2464
+ # -------- LEGACY SINGLE-CURVE --------
2465
+ # your existing single-curve behavior (slightly adjusted: store it too)
2404
2466
  want = _norm_mode(preset.get("mode"))
2405
2467
  for b in self.mode_group.buttons():
2406
2468
  if b.text().lower() == want.lower():
2407
2469
  b.setChecked(True)
2408
2470
  break
2409
2471
 
2410
- # 2) get points_norm — if absent, build from shape/amount (built-ins)
2411
2472
  ptsN = preset.get("points_norm")
2412
- shape = preset.get("shape") # may be None for custom presets
2473
+ shape = preset.get("shape")
2413
2474
  amount = float(preset.get("amount", 1.0))
2414
2475
 
2415
2476
  if not (isinstance(ptsN, (list, tuple)) and len(ptsN) >= 2):
2416
2477
  try:
2417
- # build from a named shape (built-ins); default to linear
2418
2478
  ptsN = _shape_points_norm(str(shape or "linear"), amount)
2419
2479
  except Exception:
2420
- ptsN = [(0.0, 0.0), (1.0, 1.0)] # safe fallback
2421
-
2422
- # 3) apply handles to the editor (strip exact endpoints)
2423
- pts_scene = _points_norm_to_scene(ptsN)
2424
- filt = [(x, y) for (x, y) in pts_scene if 1e-6 < x < 360.0 - 1e-6]
2425
-
2426
- if hasattr(self.editor, "clearSymmetryLine"):
2427
- self.editor.clearSymmetryLine()
2480
+ ptsN = [(0.0, 0.0), (1.0, 1.0)]
2428
2481
 
2429
- self.editor.setControlHandles(filt)
2430
- self.editor.updateCurve() # ensure redraw
2431
-
2432
- # persist into store & refresh
2482
+ self._editor_set_from_norm(ptsN)
2433
2483
  self._curves_store[self._current_mode_key] = self._editor_points_norm()
2434
2484
  self._refresh_overlays()
2435
2485
  self._quick_preview()
2436
2486
 
2437
- # 4) status: don’t assume shape exists
2438
2487
  shape_tag = f"[{shape}]" if shape else "[custom]"
2439
- self._set_status(self.tr("Preset: {0} {1}").format(preset.get('name', self.tr('(built-in)')), shape_tag))
2488
+ self._set_status(self.tr("Preset: {0} {1}").format(preset.get("name", self.tr("(built-in)")), shape_tag))
2489
+
2440
2490
 
2441
2491
 
2442
2492
  def apply_curves_ops(doc, op: dict):
@@ -12,18 +12,96 @@ except Exception:
12
12
  _HAS_PCHIP = False
13
13
 
14
14
 
15
-
16
15
  # ---------------------- preset schema ----------------------
16
+ # Legacy (v1 single-curve) preset:
17
17
  # {
18
+ # "name": "MyPreset",
18
19
  # "mode": "K (Brightness)" | "R" | "G" | "B" | "L*" | "a*" | "b*" | "Chroma" | "Saturation" | aliases ("rgb","k","lum"…),
19
- # "shape": "linear" | "s_mild" | "s_med" | "s_strong" | "lift_shadows" | "crush_shadows"
20
- # | "fade_blacks" | "rolloff_highlights" | "flatten" | "custom",
21
- # "amount": 0..1 (intensity, ignored for custom),
22
- # "points_norm": [[x,y], ...] # optional when shape="custom" (normalized 0..1 domain/range)
20
+ # "shape": "linear" | "s_mild" | ... | "custom",
21
+ # "amount": 0..1,
22
+ # "points_norm": [[x,y], ...] # normalized 0..1 domain/range
23
23
  # }
24
24
  #
25
- # Default if missing: mode="K (Brightness)", shape="linear", amount=0.5
25
+ # New (v2 multi-curve) preset:
26
+ # {
27
+ # "name": "MyPreset",
28
+ # "kind": "curves_multi",
29
+ # "version": 2,
30
+ # "active": "K" | "R" | "G" | "B" | "L*" | "a*" | "b*" | "Chroma" | "Saturation",
31
+ # "modes": {
32
+ # "K": [[x,y], ...],
33
+ # "R": [[x,y], ...],
34
+ # ...
35
+ # }
36
+ # }
37
+
38
+ _MODE_KEY_TO_LABEL = {
39
+ "K": "K (Brightness)",
40
+ "R": "R",
41
+ "G": "G",
42
+ "B": "B",
43
+ "L*": "L*",
44
+ "a*": "a*",
45
+ "b*": "b*",
46
+ "Chroma": "Chroma",
47
+ "Saturation": "Saturation",
48
+ }
26
49
 
50
+
51
+ _LABEL_TO_MODE_KEY = {v: k for k, v in _MODE_KEY_TO_LABEL.items()}
52
+
53
+ _APPLY_ORDER_KEYS = ["K", "R", "G", "B", "L*", "a*", "b*", "Chroma", "Saturation"]
54
+
55
+ def _is_linear_points_norm(pts) -> bool:
56
+ try:
57
+ return (
58
+ isinstance(pts, (list, tuple)) and len(pts) == 2
59
+ and tuple(pts[0]) == (0.0, 0.0)
60
+ and tuple(pts[1]) == (1.0, 1.0)
61
+ )
62
+ except Exception:
63
+ return False
64
+
65
+ def _coerce_points_norm(raw) -> List[Tuple[float, float]] | None:
66
+ if not isinstance(raw, (list, tuple)) or len(raw) < 2:
67
+ return None
68
+ out: List[Tuple[float, float]] = []
69
+ for e in raw:
70
+ if isinstance(e, (list, tuple)) and len(e) >= 2:
71
+ x, y = e[0], e[1]
72
+ elif isinstance(e, dict):
73
+ x, y = e.get("x"), e.get("y")
74
+ else:
75
+ continue
76
+ if x is None or y is None:
77
+ continue
78
+ out.append((float(x), float(y)))
79
+ return out if len(out) >= 2 else None
80
+
81
+ def _coerce_modes_dict(raw) -> Dict[str, List[Tuple[float, float]]]:
82
+ """
83
+ Return {mode_key: points_norm}, filtering junk but keeping backward-compat.
84
+ Accepts keys as internal mode keys ("K") or labels ("K (Brightness)").
85
+ """
86
+ modes: Dict[str, List[Tuple[float, float]]] = {}
87
+ if not isinstance(raw, dict):
88
+ return modes
89
+
90
+ for k, v in raw.items():
91
+ key = str(k)
92
+ # allow labels or keys
93
+ if key in _MODE_KEY_TO_LABEL:
94
+ mode_key = key
95
+ else:
96
+ mode_label = _norm_mode(key) # converts aliases -> proper label
97
+ mode_key = _LABEL_TO_MODE_KEY.get(mode_label, "K")
98
+
99
+ pts = _coerce_points_norm(v)
100
+ if pts is None:
101
+ continue
102
+ modes[mode_key] = pts
103
+
104
+ return modes
27
105
  # ---------------------- shape library (normalized) ----------------------
28
106
  def _shape_points_norm(shape: str, amount: float) -> List[Tuple[float, float]]:
29
107
  a = float(max(0.0, min(1.0, amount)))
@@ -141,32 +219,38 @@ def _unwrap_preset_dict(preset: Dict) -> Dict:
141
219
  """
142
220
  Accept a variety of containers and peel down to the actual curve data.
143
221
 
144
- Examples we handle:
145
- {"step_name":"Curves","mode":..., "preset":{...}}
146
- {"curves": {...}}
147
- {"state": {...}} # if state contains curve points
222
+ Handles:
223
+ - {"preset": {...}}
224
+ - {"curves": {...}}
225
+ - {"state": {...}}
226
+ - multi presets with {"modes": {...}}
148
227
  """
149
228
  p = dict(preset or {})
150
229
 
151
- # Case 1: full metadata from doc.apply_edit: {"step_name":"Curves", "mode":..., "preset": {...}}
230
+ def _looks_like_curve_dict(d: dict) -> bool:
231
+ return (
232
+ isinstance(d, dict)
233
+ and (
234
+ "points_norm" in d
235
+ or "handles" in d
236
+ or "points_scene" in d
237
+ or "scene_points" in d
238
+ or "modes" in d # <-- multi
239
+ )
240
+ )
241
+
152
242
  inner = p.get("preset")
153
- if isinstance(inner, dict) and ("points_norm" in inner or "handles" in inner
154
- or "points_scene" in inner or "scene_points" in inner):
243
+ if _looks_like_curve_dict(inner):
155
244
  return inner
156
245
 
157
- # Case 2: payloads like {"curves": {...}}
158
246
  inner = p.get("curves")
159
- if isinstance(inner, dict) and ("points_norm" in inner or "handles" in inner
160
- or "points_scene" in inner or "scene_points" in inner):
247
+ if _looks_like_curve_dict(inner):
161
248
  return inner
162
249
 
163
- # Case 3: {"state": {...}} (if you stored the curve state under that key)
164
250
  inner = p.get("state")
165
- if isinstance(inner, dict) and ("points_norm" in inner or "handles" in inner
166
- or "points_scene" in inner or "scene_points" in inner):
251
+ if _looks_like_curve_dict(inner):
167
252
  return inner
168
253
 
169
- # Otherwise assume p already *is* the preset
170
254
  return p
171
255
 
172
256
 
@@ -179,7 +263,9 @@ def _settings() -> QSettings | None:
179
263
  return None
180
264
 
181
265
  def list_custom_presets() -> list[dict]:
182
- """Return a list of dicts: {"name", "mode", "shape":"custom", "points_norm":[[x,y],...]}"""
266
+ """
267
+ Returns a list of preset dicts (v1 single-curve or v2 multi-curve).
268
+ """
183
269
  s = _settings()
184
270
  if not s:
185
271
  return []
@@ -187,29 +273,82 @@ def list_custom_presets() -> list[dict]:
187
273
  try:
188
274
  lst = json.loads(raw)
189
275
  if isinstance(lst, list):
190
- return [p for p in lst if isinstance(p, dict)]
276
+ out = [p for p in lst if isinstance(p, dict)]
277
+ # normalize missing name fields (defensive)
278
+ for p in out:
279
+ if "name" not in p:
280
+ p["name"] = "(unnamed)"
281
+ return out
191
282
  except Exception:
192
283
  pass
193
284
  return []
194
285
 
195
- def save_custom_preset(name: str, mode: str, points_norm: list[tuple[float,float]]) -> bool:
196
- """Create/overwrite by name."""
286
+ def save_custom_preset(name: str, mode_or_preset, points_norm: list[tuple[float, float]] | None = None) -> bool:
287
+ """
288
+ Save a custom preset. Supports:
289
+ - legacy: save_custom_preset(name, mode, points_norm)
290
+ - new: save_custom_preset(name, preset_dict)
291
+ """
197
292
  s = _settings()
198
293
  if not s:
199
294
  return False
295
+
200
296
  name = (name or "").strip()
201
297
  if not name:
202
298
  return False
203
- preset = {
204
- "name": name,
205
- "mode": _norm_mode(mode),
206
- "shape": "custom",
207
- "amount": 1.0,
208
- "points_norm": [(float(x), float(y)) for (x, y) in points_norm],
209
- }
299
+
300
+ # --- build preset dict ---
301
+ preset: dict
302
+
303
+ if isinstance(mode_or_preset, dict):
304
+ # v2 (or v1 dict passed directly)
305
+ preset = dict(mode_or_preset)
306
+ preset["name"] = name # enforce
307
+
308
+ # If it's a multi preset, sanitize modes
309
+ if preset.get("kind") == "curves_multi" or isinstance(preset.get("modes"), dict):
310
+ modes = _coerce_modes_dict(preset.get("modes", {}))
311
+ # fill missing keys with linear so UI always has a full set
312
+ full_modes = {}
313
+ for k in _MODE_KEY_TO_LABEL.keys():
314
+ full_modes[k] = modes.get(k, [(0.0, 0.0), (1.0, 1.0)])
315
+ preset = {
316
+ "name": name,
317
+ "kind": "curves_multi",
318
+ "version": 2,
319
+ "active": str(preset.get("active") or "K"),
320
+ "modes": {k: [(float(x), float(y)) for (x, y) in v] for k, v in full_modes.items()},
321
+ }
322
+
323
+ else:
324
+ # treat as v1 single-curve dict
325
+ mode_label = _norm_mode(preset.get("mode"))
326
+ pts = _coerce_points_norm(preset.get("points_norm")) or [(0.0, 0.0), (1.0, 1.0)]
327
+ preset = {
328
+ "name": name,
329
+ "mode": mode_label,
330
+ "shape": str(preset.get("shape", "custom")),
331
+ "amount": float(preset.get("amount", 1.0)),
332
+ "points_norm": [(float(x), float(y)) for (x, y) in pts],
333
+ }
334
+
335
+ else:
336
+ # legacy signature
337
+ mode = _norm_mode(str(mode_or_preset))
338
+ pts = points_norm or [(0.0, 0.0), (1.0, 1.0)]
339
+ preset = {
340
+ "name": name,
341
+ "mode": mode,
342
+ "shape": "custom",
343
+ "amount": 1.0,
344
+ "points_norm": [(float(x), float(y)) for (x, y) in pts],
345
+ }
346
+
347
+ # --- upsert by name (case-insensitive) ---
210
348
  lst = list_custom_presets()
211
- lst = [p for p in lst if (p.get("name","").lower() != name.lower())]
349
+ lst = [p for p in lst if (p.get("name", "").lower() != name.lower())]
212
350
  lst.append(preset)
351
+
213
352
  s.setValue(_SETTINGS_KEY, json.dumps(lst))
214
353
  s.sync()
215
354
  return True
@@ -218,8 +357,11 @@ def delete_custom_preset(name: str) -> bool:
218
357
  s = _settings()
219
358
  if not s:
220
359
  return False
360
+ nm = (name or "").strip().lower()
361
+ if not nm:
362
+ return False
221
363
  lst = list_custom_presets()
222
- lst = [p for p in lst if (p.get("name","").lower() != (name or "").strip().lower())]
364
+ lst = [p for p in lst if (p.get("name", "").strip().lower() != nm)]
223
365
  s.setValue(_SETTINGS_KEY, json.dumps(lst))
224
366
  s.sync()
225
367
  return True
@@ -228,7 +370,7 @@ def delete_custom_preset(name: str) -> bool:
228
370
  # ---------------------- headless apply ----------------------
229
371
  def apply_curves_via_preset(main_window, doc, preset: Dict):
230
372
  import numpy as _np
231
- from setiastro.saspro.curves_preset import _lut_from_preset, _unwrap_preset_dict # self
373
+ from setiastro.saspro.curves_preset import _unwrap_preset_dict # self
232
374
  # lazy import to avoid cycle
233
375
  from setiastro.saspro.curve_editor_pro import _apply_mode_any
234
376
 
@@ -236,8 +378,7 @@ def apply_curves_via_preset(main_window, doc, preset: Dict):
236
378
  if img is None:
237
379
  return
238
380
 
239
- # Accept full last-action dicts and unwrap down to the actual curve definition
240
- core_preset = _unwrap_preset_dict(preset or {})
381
+ core = _unwrap_preset_dict(preset or {})
241
382
 
242
383
  arr = _np.asarray(img)
243
384
  if arr.dtype.kind in "ui":
@@ -248,18 +389,52 @@ def apply_curves_via_preset(main_window, doc, preset: Dict):
248
389
  else:
249
390
  arr01 = arr.astype(_np.float32)
250
391
 
251
- lut01, mode = _lut_from_preset(core_preset)
392
+ # -------- MULTI --------
393
+ if core.get("kind") == "curves_multi" or isinstance(core.get("modes"), dict):
394
+ modes = _coerce_modes_dict(core.get("modes", {}))
395
+
396
+ out01 = arr01
397
+ used_any = False
398
+
399
+ for mode_key in _APPLY_ORDER_KEYS:
400
+ pts = modes.get(mode_key)
401
+ if not pts:
402
+ continue
403
+ if _is_linear_points_norm(pts):
404
+ continue
405
+
406
+ pts_scene = _points_norm_to_scene(pts)
407
+ fn = _interpolator_from_scene_points(pts_scene)
408
+ lut01 = build_curve_lut(fn, size=65536)
409
+
410
+ mode_label = _MODE_KEY_TO_LABEL.get(mode_key, "K (Brightness)")
411
+ out01 = _apply_mode_any(out01, mode_label, lut01)
412
+ used_any = True
413
+
414
+ if not used_any:
415
+ return
416
+
417
+ meta = {
418
+ "step_name": "Curves",
419
+ "mode": _MODE_KEY_TO_LABEL.get(str(core.get("active") or "K"), "K (Brightness)"),
420
+ "preset": dict(core),
421
+ }
422
+ doc.apply_edit(out01, metadata=meta, step_name="Curves")
423
+ return
424
+
425
+ # -------- LEGACY SINGLE --------
426
+ lut01, mode = _lut_from_preset(core)
252
427
  out01 = _apply_mode_any(arr01, mode, lut01)
253
428
 
254
429
  meta = {
255
430
  "step_name": "Curves",
256
431
  "mode": mode,
257
- "preset": dict(core_preset), # store the normalized core preset
432
+ "preset": dict(core),
258
433
  }
259
434
  doc.apply_edit(out01, metadata=meta, step_name="Curves")
260
435
 
261
436
 
262
- # ---------------------- open UI with preset ----------------------
437
+
263
438
  # ---------------------- open UI with preset ----------------------
264
439
  def open_curves_with_preset(main_window, preset: Dict | None = None):
265
440
  # lazy import UI to avoid cycle
@@ -274,20 +449,47 @@ def open_curves_with_preset(main_window, preset: Dict | None = None):
274
449
 
275
450
  dlg = CurvesDialogPro(main_window, doc)
276
451
 
277
- # Peel down any wrapper (metadata / last-action container) to the actual curve definition
278
- core_preset = _unwrap_preset_dict(preset or {})
452
+ core = _unwrap_preset_dict(preset or {})
453
+
454
+ # -------- MULTI --------
455
+ if core.get("kind") == "curves_multi" or isinstance(core.get("modes"), dict):
456
+ modes = _coerce_modes_dict(core.get("modes", {}))
457
+
458
+ # Fill dialog store for every key
459
+ for k in getattr(dlg, "_curves_store", {}).keys():
460
+ dlg._curves_store[k] = modes.get(k, [(0.0, 0.0), (1.0, 1.0)])
461
+
462
+ # Choose active key
463
+ active_key = str(core.get("active") or "K")
464
+ if active_key not in dlg._curves_store:
465
+ active_key = "K"
466
+ dlg._current_mode_key = active_key
467
+
468
+ # Set the radio to match active
469
+ want_label = _MODE_KEY_TO_LABEL.get(active_key, "K (Brightness)")
470
+ for b in dlg.mode_group.buttons():
471
+ if b.text().lower() == want_label.lower():
472
+ b.setChecked(True)
473
+ break
474
+
475
+ # Push active curve into editor
476
+ dlg._editor_set_from_norm(dlg._curves_store.get(active_key, [(0.0, 0.0), (1.0, 1.0)]))
477
+ dlg._refresh_overlays()
478
+ dlg._quick_preview()
479
+
480
+ dlg.show()
481
+ dlg.raise_()
482
+ dlg.activateWindow()
483
+ return
279
484
 
280
- # set mode radio from the *core* preset
281
- want = _norm_mode(core_preset.get("mode"))
485
+ # -------- LEGACY SINGLE --------
486
+ want = _norm_mode(core.get("mode"))
282
487
  for b in dlg.mode_group.buttons():
283
488
  if b.text().lower() == want.lower():
284
489
  b.setChecked(True)
285
490
  break
286
491
 
287
- # Seed control handles from the same logic used by LUT building
288
- pts_scene = _scene_points_from_preset(core_preset)
289
-
290
- # remove exact endpoints if present; editor expects control handles only
492
+ pts_scene = _scene_points_from_preset(core)
291
493
  filt = [(x, y) for (x, y) in pts_scene if x > 0.0 + 1e-6 and x < 360.0 - 1e-6]
292
494
  dlg.editor.setControlHandles(filt)
293
495
 
@@ -2276,6 +2276,7 @@ class DocManager(QObject):
2276
2276
  bit_depth: str | None = None,
2277
2277
  *,
2278
2278
  bit_depth_override: str | None = None,
2279
+ jpeg_quality: int | None = None, # <-- NEW
2279
2280
  ):
2280
2281
  """
2281
2282
  Save the given ImageDocument to 'path'.
@@ -2289,7 +2290,8 @@ class DocManager(QObject):
2289
2290
  ext = _normalize_ext(os.path.splitext(path)[1])
2290
2291
  img = doc.image
2291
2292
  meta = doc.metadata or {}
2292
-
2293
+ if jpeg_quality is not None:
2294
+ meta["jpeg_quality"] = int(jpeg_quality)
2293
2295
  # ── MASSIVE DEBUG: show everything we know coming in ───────────────
2294
2296
  debug_dump_metadata_print(meta, context="save_document: BEFORE HEADER PICK")
2295
2297
 
@@ -2359,6 +2361,7 @@ class DocManager(QObject):
2359
2361
  image_meta=meta.get("image_meta"),
2360
2362
  file_meta=meta.get("file_meta"),
2361
2363
  wcs_header=meta.get("wcs_header"),
2364
+ jpeg_quality=jpeg_quality,
2362
2365
  )
2363
2366
 
2364
2367
  # ── Update metadata in memory to match what we just wrote ─────────