diff --git a/gnommo/models.py b/gnommo/models.py index 31f4967..ca50416 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -26,6 +26,15 @@ class CutoutDefinition: height_percent: float = 0.0 # percentage (0.0-1.0) if height is -1 width_percent: float = 0.0 # percentage (0.0-1.0) if width is -1 + # New center-based model (opt-in via cx/cy in project.json). When cx_percent is + # set: cx/cy are the CENTER as fractions of frame width/height; width_percent and + # height_percent are fractions of min(W,H) (so equal values → a true square on any + # aspect ratio); margin_percent is a per-side inset, also a fraction of min(W,H). + cx_percent: Optional[float] = None + cy_percent: Optional[float] = None + margin_percent: float = 0.0 + size_relative_to_min: bool = False + # Backwards compatibility alias TalkingHeadConfig = CutoutDefinition diff --git a/gnommo/parser.py b/gnommo/parser.py index f79229f..f817071 100644 --- a/gnommo/parser.py +++ b/gnommo/parser.py @@ -223,6 +223,24 @@ def parse_project_config(project_path: Path) -> ProjectConfig: # override the built-ins above. cutouts_data = data.get("cutouts", {}) for cutout_name, cutout_data in cutouts_data.items(): + if "cx" in cutout_data or "cy" in cutout_data: + # New center-based model: cx/cy are the center (fractions of frame W/H); + # width/height/margin are fractions of min(W,H). width==height → square. + w_pct = _parse_percent(cutout_data.get("width", "100%")) + h_pct = _parse_percent(cutout_data.get("height", cutout_data.get("width", "100%"))) + cutouts[cutout_name] = CutoutDefinition( + x=-1, + y=-1, + height=-1, + width=-1, + cx_percent=_parse_percent(cutout_data.get("cx", "50%")), + cy_percent=_parse_percent(cutout_data.get("cy", "50%")), + width_percent=w_pct, + height_percent=h_pct, + margin_percent=_parse_percent(cutout_data.get("margin", "0%")), + size_relative_to_min=True, + ) + continue x, x_pct = _parse_dimension(cutout_data.get("x", 0)) y, y_pct = _parse_dimension(cutout_data.get("y", 0)) height, height_pct = _parse_dimension(cutout_data.get("height", 200)) @@ -290,6 +308,20 @@ def _parse_dimension(value: Any) -> tuple[int, float]: return 200, 0.0 # default +def _parse_percent(value: Any, default: float = 0.0) -> float: + """Parse a percentage into a 0.0-based fraction. '177%' → 1.77; a bare number + is taken as an already-computed fraction (1.77 → 1.77). Used by the center-based + cutout model where values can exceed 100% (fullscreen width on 16:9).""" + if value is None: + return default + if isinstance(value, str): + v = value.strip() + if v.endswith("%"): + return float(v[:-1]) / 100.0 + return float(v) + return float(value) + + def parse_slides( project_path: Path, config: ProjectConfig = None ) -> dict[str, SlideDefinition]: diff --git a/gnommo/renderer.py b/gnommo/renderer.py index 2868fc7..c105bea 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -675,8 +675,24 @@ def _calculate_cutout_position( ) -> tuple[int, int, int, int]: """Calculate pixel position, width, and height from cutout definition. - Returns: (x, y, width, height) + Returns: (x, y, width, height) — top-left corner + size, in pixels. """ + # New center-based model: cx/cy are the center as fractions of frame W/H; + # width/height/margin are fractions of min(W,H) so a square stays square on any + # aspect ratio. margin insets each side. + if cutout.cx_percent is not None: + m = min(frame_width, frame_height) + margin_px = cutout.margin_percent * m + cut_width = int(round(cutout.width_percent * m - 2 * margin_px)) + cut_height = int(round(cutout.height_percent * m - 2 * margin_px)) + cut_width = max(0, cut_width) + cut_height = max(0, cut_height) + center_x = cutout.cx_percent * frame_width + center_y = cutout.cy_percent * frame_height + cut_x = int(round(center_x - cut_width / 2)) + cut_y = int(round(center_y - cut_height / 2)) + return cut_x, cut_y, cut_width, cut_height + # Calculate height if cutout.height >= 0: cut_height = cutout.height