phase 15
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s
This commit is contained in:
62
backend/app/images/download.py
Normal file
62
backend/app/images/download.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Image download functionality."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.core.storage import storage_client
|
||||
|
||||
|
||||
async def download_single_image(storage_path: str, filename: str) -> StreamingResponse:
|
||||
"""
|
||||
Download a single image from storage.
|
||||
|
||||
Args:
|
||||
storage_path: Path to image in MinIO
|
||||
filename: Original filename for download
|
||||
|
||||
Returns:
|
||||
StreamingResponse with image data
|
||||
|
||||
Raises:
|
||||
HTTPException: If image not found or download fails
|
||||
"""
|
||||
try:
|
||||
# Get image from storage
|
||||
image_data = storage_client.get_object(storage_path)
|
||||
|
||||
if image_data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found in storage",
|
||||
)
|
||||
|
||||
# Determine content type from file extension
|
||||
extension = Path(filename).suffix.lower()
|
||||
content_type_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
content_type = content_type_map.get(extension, "application/octet-stream")
|
||||
|
||||
# Return streaming response
|
||||
return StreamingResponse(
|
||||
io.BytesIO(image_data),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to download image: {str(e)}",
|
||||
) from e
|
||||
228
backend/app/images/export_composite.py
Normal file
228
backend/app/images/export_composite.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""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
|
||||
103
backend/app/images/export_zip.py
Normal file
103
backend/app/images/export_zip.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""ZIP export functionality for multiple images."""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.storage import storage_client
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
|
||||
|
||||
def create_zip_export(board_id: str, db: Session) -> StreamingResponse:
|
||||
"""
|
||||
Create a ZIP file containing all images from a board.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
StreamingResponse with ZIP file
|
||||
|
||||
Raises:
|
||||
HTTPException: If export fails
|
||||
"""
|
||||
try:
|
||||
# Get all images for the board
|
||||
board_images = (
|
||||
db.query(BoardImage, Image)
|
||||
.join(Image, BoardImage.image_id == Image.id)
|
||||
.filter(BoardImage.board_id == board_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not board_images:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No images found for this board",
|
||||
)
|
||||
|
||||
# Create ZIP file in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for _board_image, image in board_images:
|
||||
try:
|
||||
# Get image data from storage
|
||||
image_data = storage_client.get_object(image.storage_path)
|
||||
|
||||
if image_data:
|
||||
# Add to ZIP with sanitized filename
|
||||
safe_filename = _sanitize_filename(image.filename)
|
||||
zip_file.writestr(safe_filename, image_data)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but continue with other images
|
||||
print(f"Warning: Failed to add {image.filename} to ZIP: {str(e)}")
|
||||
continue
|
||||
|
||||
# Reset buffer position
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# Return ZIP file
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="board_export.zip"',
|
||||
"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 ZIP export: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize filename for safe inclusion in ZIP.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
# Remove any path separators and dangerous characters
|
||||
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- ")
|
||||
sanitized = "".join(c if c in safe_chars else "_" for c in filename)
|
||||
|
||||
# Ensure it's not empty and doesn't start with a dot
|
||||
if not sanitized or sanitized[0] == ".":
|
||||
sanitized = "file_" + sanitized
|
||||
|
||||
return sanitized
|
||||
Reference in New Issue
Block a user