"""Composite image generation for board export.""" import io from fastapi import HTTPException, status from fastapi.responses import StreamingResponse from PIL import Image as PILImage from sqlalchemy.orm import Session from app.core.storage import storage_client from app.database.models.board import Board from app.database.models.board_image import BoardImage from app.database.models.image import Image def create_composite_export(board_id: str, db: Session, scale: float = 1.0, format: str = "PNG") -> StreamingResponse: """ Create a composite image showing the entire board layout. Args: board_id: Board UUID db: Database session scale: Resolution multiplier (1x, 2x, 4x) format: Output format (PNG or JPEG) Returns: StreamingResponse with composite image Raises: HTTPException: If export fails """ try: # Get board board = db.query(Board).filter(Board.id == board_id).first() if not board: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Board not found", ) # Get all images for the board with positions board_images = ( db.query(BoardImage, Image) .join(Image, BoardImage.image_id == Image.id) .filter(BoardImage.board_id == board_id) .order_by(BoardImage.z_order) .all() ) if not board_images: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No images found for this board", ) # Calculate canvas bounds bounds = _calculate_canvas_bounds(board_images) if not bounds: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Unable to calculate canvas bounds", ) min_x, min_y, max_x, max_y = bounds # Calculate canvas size with padding padding = 50 canvas_width = int((max_x - min_x + 2 * padding) * scale) canvas_height = int((max_y - min_y + 2 * padding) * scale) # Limit canvas size to prevent memory issues max_dimension = 8192 # 8K resolution limit if canvas_width > max_dimension or canvas_height > max_dimension: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Composite image too large (max {max_dimension}x{max_dimension})", ) # Create blank canvas if format.upper() == "JPEG": canvas = PILImage.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255)) else: canvas = PILImage.new("RGBA", (canvas_width, canvas_height), color=(255, 255, 255, 255)) # Composite each image onto canvas for board_image, image in board_images: try: # Get image from storage image_data = storage_client.get_object(image.storage_path) if not image_data: continue # Open image pil_image = PILImage.open(io.BytesIO(image_data)) # Apply transformations transformed_image = _apply_transformations(pil_image, board_image.transformations, scale) # Calculate position on canvas pos = board_image.position x = int((pos["x"] - min_x + padding) * scale) y = int((pos["y"] - min_y + padding) * scale) # Paste onto canvas if transformed_image.mode == "RGBA": canvas.paste(transformed_image, (x, y), transformed_image) else: canvas.paste(transformed_image, (x, y)) except Exception as e: # Log error but continue with other images print(f"Warning: Failed to composite {image.filename}: {str(e)}") continue # Save to buffer output = io.BytesIO() if format.upper() == "JPEG": canvas = canvas.convert("RGB") canvas.save(output, format="JPEG", quality=95) media_type = "image/jpeg" extension = "jpg" else: canvas.save(output, format="PNG", optimize=True) media_type = "image/png" extension = "png" output.seek(0) # Return composite image return StreamingResponse( output, media_type=media_type, headers={ "Content-Disposition": f'attachment; filename="board_composite.{extension}"', "Cache-Control": "no-cache", }, ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create composite export: {str(e)}", ) from e def _calculate_canvas_bounds(board_images) -> tuple[float, float, float, float] | None: """ Calculate the bounding box for all images. Args: board_images: List of (BoardImage, Image) tuples Returns: Tuple of (min_x, min_y, max_x, max_y) or None """ if not board_images: return None min_x = min_y = float("inf") max_x = max_y = float("-inf") for board_image, image in board_images: pos = board_image.position transforms = board_image.transformations x = pos["x"] y = pos["y"] width = image.width * transforms.get("scale", 1.0) height = image.height * transforms.get("scale", 1.0) min_x = min(min_x, x) min_y = min(min_y, y) max_x = max(max_x, x + width) max_y = max(max_y, y + height) return (min_x, min_y, max_x, max_y) def _apply_transformations(image: PILImage.Image, transformations: dict, scale: float) -> PILImage.Image: """ Apply transformations to an image. Args: image: PIL Image transformations: Transformation dict scale: Resolution multiplier Returns: Transformed PIL Image """ # Apply scale img_scale = transformations.get("scale", 1.0) * scale if img_scale != 1.0: new_width = int(image.width * img_scale) new_height = int(image.height * img_scale) image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS) # Apply rotation rotation = transformations.get("rotation", 0) if rotation != 0: image = image.rotate(-rotation, expand=True, resample=PILImage.Resampling.BICUBIC) # Apply flips if transformations.get("flipped_h", False): image = image.transpose(PILImage.Transpose.FLIP_LEFT_RIGHT) if transformations.get("flipped_v", False): image = image.transpose(PILImage.Transpose.FLIP_TOP_BOTTOM) # Apply greyscale if transformations.get("greyscale", False): if image.mode == "RGBA": # Preserve alpha channel alpha = image.split()[-1] image = image.convert("L").convert("RGBA") image.putalpha(alpha) else: image = image.convert("L") # Apply opacity opacity = transformations.get("opacity", 1.0) if opacity < 1.0 and image.mode in ("RGBA", "LA"): alpha = image.split()[-1] alpha = alpha.point(lambda p: int(p * opacity)) image.putalpha(alpha) return image