"""Frozen Pydantic value-objects for the studio's UI state — one validated form per generation mode. The Gradio views collect raw widget values into these forms at the request boundary (see :mod:`diffu_studio.controls`), so the streaming services in :mod:`diffu_studio.handlers` take ONE typed form instead of the 24-plus positional arguments the old god-handlers carried. Each form knows how to lower itself to the real ``diffu-page`` config it drives, so the knob names live in exactly one place. """ from __future__ import annotations import re from diffu_page.augment import AugmentConfig from diffu_page.config import GenConfig from pydantic import BaseModel, Field from diffu_studio.configs import SampleSettings, build_augment, build_gen_config from diffu_studio.page import PageRequest # Presence gates: on a corpus these are "fraction of pages that get the effect", but in the single-page studio # a probability is meaningless — for one page + one seed the gate either fires or not, so nudging it looks dead. # The studio forces each to a deterministic on/off (>0 → always apply), so a knob visibly changes THIS page. # (The seed still re-rolls WHERE/how the effect lands — see the Reroll button.) _AUGMENT_GATES = frozenset({ "bed_prob", "microfilm_prob", "correction_prob", "stamp_prob", "fold_prob", "dust_prob", "clasp_prob", "glove_prob", "hole_prob", "curl_prob", "paraph_prob", "margin_mark_prob", }) # fmt: skip def _force(prob: float) -> float: """A presence probability → a deterministic gate: any positive value always applies, 0 never does.""" return 1.0 if prob > 0 else 0.0 class AugmentForm(BaseModel): """The curated scan-look knobs — mirrors :func:`diffu_studio.configs.build_augment` field-for-field.""" model_config = {"frozen": True} bed_prob: float = 0.7 microfilm_prob: float = 0.12 correction_prob: float = 0.15 stamp_prob: float = 0.12 fold_prob: float = 0.2 dust_prob: float = 0.35 rotate_max: float = 2.5 wash_max: float = 0.35 ink_fade_max: float = 0.18 skew: float = 0.008 ink_color: str | None = None vignette_max: float = 0.42 bleed_max: float = 0.13 spine_shadow_max: float = 0.72 spine_warp_max: float = 0.15 gutter_blur_max: float = 3.0 edge_damage_max: float = 0.007 clasp_prob: float = 0.2 glove_prob: float = 0.15 hole_prob: float = 0.06 curl_prob: float = 0.15 paraph_prob: float = 0.0 margin_mark_prob: float = 0.0 foxing_tint: str | None = None def to_augment(self) -> AugmentConfig: """Lower to the real :class:`AugmentConfig`, forcing presence gates deterministic (studio single-page).""" values = self.model_dump() for name in _AUGMENT_GATES: values[name] = _force(values[name]) return build_augment(**values) class LineForm(BaseModel): """Single-line generation state — the text plus the shared sampling settings (no model choice).""" model_config = {"frozen": True} text: str settings: SampleSettings = Field(default_factory=SampleSettings) class PageForm(BaseModel): """The whole-page control state — flat knob values plus a nested :class:`AugmentForm`.""" model_config = {"frozen": True} text: str = "" seed: int = 0 page_number: str | None = None xml_layout: str | None = None formats: list[str] = Field(default_factory=lambda: ["page", "alto", "json"]) steps: int = 24 cfg_scale: float = 5.0 template: str = "single_column" n_columns: int = 2 width: int = 1240 height: int = 1754 line_height: int = 100 line_gap: float = 0.18 para_gap: float = 0.4 furniture_prob: float = 0.5 typeset_furniture: bool = False binding_prob: float = 0.4 insertion_prob: float = 0.012 deletion_prob: float = 0.008 augment: AugmentForm = Field(default_factory=AugmentForm) def gen_config(self) -> GenConfig: """Lower the page knobs to a validated :class:`GenConfig` (raises on out-of-range values).""" return build_gen_config( width=self.width, height=self.height, template=self.template, n_columns=self.n_columns, line_height=self.line_height, line_gap=self.line_gap, para_gap=self.para_gap, furniture_prob=_force( self.furniture_prob ), # deterministic on/off in the studio (see _AUGMENT_GATES) typeset_furniture=self.typeset_furniture, binding_prob=_force(self.binding_prob), # "binding prob" → a real toggle for this page insertion_prob=self.insertion_prob, deletion_prob=self.deletion_prob, steps=self.steps, cfg_scale=self.cfg_scale, formats=self.formats, augment=self.augment.to_augment(), ) def to_request(self, *, animate: bool) -> PageRequest: """Assemble the :class:`PageRequest` the page stream consumes. Paragraphs are separated by BLANK lines (standard prose convention); single newlines flow together into one paragraph and re-wrap to the layout. (Previously every newline made its own paragraph, so a few lines of text got a paragraph gap between each — the page looked far airier than intended.) """ return PageRequest( paragraphs=[ " ".join(block.split()) for block in re.split(r"\n\s*\n", self.text) if block.strip() ], gen=self.gen_config(), settings=SampleSettings(steps=self.steps, cfg_scale=self.cfg_scale), seed=self.seed, page_number=self.page_number or None, xml_layout=self.xml_layout, animate=animate, )