Spaces:
Running
Running
| """ | |
| Image Grid Cutter - A Gradio app to split images into a customizable grid | |
| """ | |
| import gradio as gr | |
| from PIL import Image, ImageDraw | |
| import io | |
| import zipfile | |
| import tempfile | |
| import os | |
| import math | |
| # Ensure local URLs bypass any proxy checks and avoid Gradio API schema issues. | |
| def _ensure_no_proxy(): | |
| for key in ("NO_PROXY", "no_proxy"): | |
| val = os.environ.get(key, "") | |
| parts = [p.strip() for p in val.split(",") if p.strip()] | |
| for host in ("127.0.0.1", "localhost"): | |
| if host not in parts: | |
| parts.append(host) | |
| os.environ[key] = ",".join(parts) | |
| def _patch_gradio_schema(): | |
| try: | |
| import gradio_client.utils as grc_utils | |
| _orig = grc_utils._json_schema_to_python_type | |
| _orig_get_type = grc_utils.get_type | |
| def _json_schema_to_python_type(schema, defs=None): | |
| if isinstance(schema, bool): | |
| return "Any" | |
| return _orig(schema, defs) | |
| def _get_type(schema): | |
| if isinstance(schema, bool): | |
| return "any" | |
| return _orig_get_type(schema) | |
| grc_utils._json_schema_to_python_type = _json_schema_to_python_type | |
| grc_utils.get_type = _get_type | |
| except Exception: | |
| pass | |
| _ensure_no_proxy() | |
| _patch_gradio_schema() | |
| def split_image_into_grid(image, rows, cols): | |
| """Splits an image into a grid of smaller images, preserving full resolution.""" | |
| if image is None: | |
| return [] | |
| img_width, img_height = image.size | |
| rows = int(rows) | |
| cols = int(cols) | |
| # Calculate tile dimensions with remainder handling | |
| tile_width = img_width // cols | |
| tile_height = img_height // rows | |
| width_remainder = img_width % cols | |
| height_remainder = img_height % rows | |
| tiles = [] | |
| for row in range(rows): | |
| for col in range(cols): | |
| # Distribute remainder pixels across tiles | |
| left = col * tile_width + min(col, width_remainder) | |
| upper = row * tile_height + min(row, height_remainder) | |
| # Add extra pixel for tiles that get remainder | |
| right = left + tile_width + (1 if col < width_remainder else 0) | |
| lower = upper + tile_height + (1 if row < height_remainder else 0) | |
| # Ensure we don't exceed image bounds | |
| right = min(right, img_width) | |
| lower = min(lower, img_height) | |
| tile = image.crop((left, upper, right, lower)) | |
| tiles.append(tile) | |
| return tiles | |
| def create_grid_preview(image, rows, cols): | |
| """Creates a preview image showing the grid lines overlaid on the image.""" | |
| if image is None: | |
| return None | |
| rows = int(rows) | |
| cols = int(cols) | |
| preview = image.copy().convert("RGBA") | |
| overlay = Image.new("RGBA", preview.size, (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(overlay) | |
| img_width, img_height = image.size | |
| tile_width = img_width // cols | |
| tile_height = img_height // rows | |
| width_remainder = img_width % cols | |
| height_remainder = img_height % rows | |
| line_color = (220, 38, 38, 255) # Red | |
| line_width = max(2, min(img_width, img_height) // 200) | |
| # Draw vertical lines (matching actual tile boundaries) | |
| for col in range(1, cols): | |
| x = col * tile_width + min(col, width_remainder) | |
| draw.line([(x, 0), (x, img_height)], fill=line_color, width=line_width) | |
| # Draw horizontal lines (matching actual tile boundaries) | |
| for row in range(1, rows): | |
| y = row * tile_height + min(row, height_remainder) | |
| draw.line([(0, y), (img_width, y)], fill=line_color, width=line_width) | |
| result = Image.alpha_composite(preview, overlay) | |
| return result.convert("RGB") | |
| def process_image(image, rows, cols): | |
| """Main processing function that splits the image and returns tiles.""" | |
| if image is None: | |
| return [None] * 256, None, None | |
| rows = int(rows) | |
| cols = int(cols) | |
| tiles = split_image_into_grid(image, rows, cols) | |
| # Pad with None | |
| padded_tiles = tiles[:256] + [None] * (256 - len(tiles[:256])) | |
| # Create zip | |
| temp_dir = tempfile.gettempdir() | |
| zip_path = os.path.join(temp_dir, "grid_tiles.zip") | |
| with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: | |
| for i, tile in enumerate(tiles): | |
| img_buffer = io.BytesIO() | |
| tile.save(img_buffer, format='PNG') | |
| img_buffer.seek(0) | |
| row_num = i // cols + 1 | |
| col_num = i % cols + 1 | |
| zip_file.writestr(f'tile_r{row_num}_c{col_num}.png', img_buffer.getvalue()) | |
| # Return first tile for crop reference | |
| first_tile = tiles[0] if tiles else None | |
| return padded_tiles, zip_path, first_tile | |
| def get_cropped_image(editor_value): | |
| """Extract the cropped image from ImageEditor.""" | |
| if editor_value is None: | |
| return None | |
| if isinstance(editor_value, Image.Image): | |
| return editor_value | |
| if isinstance(editor_value, dict): | |
| if 'composite' in editor_value and editor_value['composite'] is not None: | |
| return editor_value['composite'] | |
| if 'background' in editor_value and editor_value['background'] is not None: | |
| return editor_value['background'] | |
| return None | |
| def apply_crop_to_all(original_image, rows, cols, cropped_editor, original_tile): | |
| """Apply the crop from one tile to all tiles.""" | |
| if original_image is None: | |
| return [None] * 256, None | |
| rows = int(rows) | |
| cols = int(cols) | |
| # Get the cropped image from editor | |
| cropped_img = get_cropped_image(cropped_editor) | |
| # If no crop was done or no original tile, just return original tiles | |
| if cropped_img is None or original_tile is None: | |
| tiles = split_image_into_grid(original_image, rows, cols) | |
| padded = tiles[:256] + [None] * (256 - len(tiles[:256])) | |
| return padded, None | |
| orig_w, orig_h = original_tile.size | |
| crop_w, crop_h = cropped_img.size | |
| # If same size, no crop was applied | |
| if orig_w == crop_w and orig_h == crop_h: | |
| tiles = split_image_into_grid(original_image, rows, cols) | |
| padded = tiles[:256] + [None] * (256 - len(tiles[:256])) | |
| return padded, None | |
| # Calculate how much was cropped from each side | |
| # Assume centered crop for now | |
| left_crop = (orig_w - crop_w) // 2 | |
| top_crop = (orig_h - crop_h) // 2 | |
| right_crop = orig_w - crop_w - left_crop | |
| bottom_crop = orig_h - crop_h - top_crop | |
| # Split and apply same crop to each tile | |
| tiles = split_image_into_grid(original_image, rows, cols) | |
| cropped_tiles = [] | |
| for tile in tiles: | |
| t_w, t_h = tile.size | |
| # Apply proportional crop | |
| new_left = min(left_crop, t_w - 1) | |
| new_top = min(top_crop, t_h - 1) | |
| new_right = max(new_left + 1, t_w - right_crop) | |
| new_bottom = max(new_top + 1, t_h - bottom_crop) | |
| if new_right > new_left and new_bottom > new_top: | |
| cropped = tile.crop((new_left, new_top, new_right, new_bottom)) | |
| else: | |
| cropped = tile | |
| cropped_tiles.append(cropped) | |
| # Create zip | |
| temp_dir = tempfile.gettempdir() | |
| zip_path = os.path.join(temp_dir, "cropped_tiles.zip") | |
| with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: | |
| for i, tile in enumerate(cropped_tiles): | |
| img_buffer = io.BytesIO() | |
| tile.save(img_buffer, format='PNG') | |
| img_buffer.seek(0) | |
| row_num = i // cols + 1 | |
| col_num = i % cols + 1 | |
| zip_file.writestr(f'tile_r{row_num}_c{col_num}.png', img_buffer.getvalue()) | |
| padded = cropped_tiles[:256] + [None] * (256 - len(cropped_tiles[:256])) | |
| return padded, zip_path | |
| def create_tiles_zip(tiles, cols): | |
| """Create a ZIP file from tiles list.""" | |
| if not tiles: | |
| return None | |
| cols = int(cols) | |
| temp_dir = tempfile.gettempdir() | |
| zip_path = os.path.join(temp_dir, "edited_tiles.zip") | |
| with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: | |
| for i, tile in enumerate(tiles): | |
| if tile is not None: | |
| img_buffer = io.BytesIO() | |
| tile.save(img_buffer, format='PNG') | |
| img_buffer.seek(0) | |
| row_num = i // cols + 1 | |
| col_num = i % cols + 1 | |
| zip_file.writestr(f'tile_r{row_num}_c{col_num}.png', img_buffer.getvalue()) | |
| return zip_path | |
| def generate_gif_from_tile_list(tiles, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type="Transparent", bg_color="#f0f0f0"): | |
| """Generate an animated GIF from a list of tiles.""" | |
| if not tiles: | |
| return None | |
| frame_duration = int(frame_duration) | |
| # Determine background color | |
| if bg_type == "Transparent": | |
| bg_rgba = (0, 0, 0, 0) | |
| else: | |
| hex_color = bg_color.lstrip('#') | |
| r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) | |
| bg_rgba = (r, g, b, 255) | |
| # Get MAX dimensions from all tiles (handles individually cropped tiles) | |
| tile_width = max(t.width for t in tiles) | |
| tile_height = max(t.height for t in tiles) | |
| img_width = tile_width * cols | |
| img_height = tile_height * rows | |
| frames = [] | |
| def parse_bg_rgb(bg_type_val, bg_color_val): | |
| if bg_type_val == "Transparent": | |
| return (255, 255, 255) | |
| hex_color = bg_color_val.lstrip('#') | |
| return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) | |
| def build_gif_palette(sample_frames, bg_type_val, bg_color_val): | |
| if not sample_frames: | |
| return None | |
| sample_count = min(10, len(sample_frames)) | |
| if sample_count == 1: | |
| indices = [0] | |
| else: | |
| indices = [round(i * (len(sample_frames) - 1) / (sample_count - 1)) for i in range(sample_count)] | |
| samples = [] | |
| bg_rgb_local = parse_bg_rgb(bg_type_val, bg_color_val) | |
| sample_size = 200 | |
| for i in indices: | |
| frame = sample_frames[i].convert("RGBA") | |
| base = Image.new("RGB", frame.size, bg_rgb_local) | |
| base.paste(frame, mask=frame.split()[3]) | |
| samples.append(base.resize((sample_size, sample_size), Image.LANCZOS)) | |
| cols_local = min(5, len(samples)) | |
| rows_local = int(math.ceil(len(samples) / cols_local)) | |
| mosaic = Image.new("RGB", (sample_size * cols_local, sample_size * rows_local), bg_rgb_local) | |
| for idx, s in enumerate(samples): | |
| x = (idx % cols_local) * sample_size | |
| y = (idx // cols_local) * sample_size | |
| mosaic.paste(s, (x, y)) | |
| palette_img = mosaic.convert("P", palette=Image.ADAPTIVE, colors=255) | |
| palette = palette_img.getpalette() | |
| palette[255 * 3:255 * 3 + 3] = [255, 0, 255] | |
| palette_img.putpalette(palette) | |
| return palette_img | |
| def save_gif(frames_list, path, duration_ms, bg_type_val, bg_color_val, tail_pause=False): | |
| if not frames_list: | |
| return None | |
| palette_img = build_gif_palette(frames_list, bg_type_val, bg_color_val) | |
| bg_rgb_local = parse_bg_rgb(bg_type_val, bg_color_val) | |
| frames_p = [] | |
| for f in frames_list: | |
| f_rgba = f.convert("RGBA") | |
| if bg_type_val == "Transparent": | |
| mask = f_rgba.split()[3] | |
| base = Image.new("RGB", f_rgba.size, (255, 0, 255)) | |
| base.paste(f_rgba, mask=mask) | |
| f_p = base.quantize(palette=palette_img, dither=Image.NONE) | |
| else: | |
| base = Image.new("RGB", f_rgba.size, bg_rgb_local) | |
| base.paste(f_rgba, mask=f_rgba.split()[3]) | |
| f_p = base.quantize(palette=palette_img, dither=Image.NONE) | |
| frames_p.append(f_p) | |
| durations = [duration_ms] * len(frames_p) | |
| if tail_pause and len(durations) > 0: | |
| durations[-1] = duration_ms * 3 | |
| save_kwargs = dict(save_all=True, append_images=frames_p[1:], duration=durations, loop=0, disposal=2) | |
| if bg_type_val == "Transparent": | |
| save_kwargs["transparency"] = 255 | |
| frames_p[0].save(path, **save_kwargs) | |
| return path | |
| if single_tile_mode: | |
| # Normalize all frames to same size (max dimensions), center smaller tiles | |
| for tile in tiles: | |
| frame = Image.new("RGBA", (tile_width, tile_height), bg_rgba) | |
| x = (tile_width - tile.width) // 2 | |
| y = (tile_height - tile.height) // 2 | |
| tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| if frames: | |
| temp_dir = tempfile.gettempdir() | |
| gif_path = os.path.join(temp_dir, "grid_animation.gif") | |
| return save_gif(frames, gif_path, frame_duration, bg_type, bg_color) | |
| return None | |
| if animation_type == "Reveal (build up)": | |
| for i in range(len(tiles) + 1): | |
| frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| for j in range(i): | |
| row = j // cols | |
| col = j % cols | |
| x = col * tile_width + (tile_width - tiles[j].width) // 2 | |
| y = row * tile_height + (tile_height - tiles[j].height) // 2 | |
| tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Cycle (one at a time)": | |
| for i, tile in enumerate(tiles): | |
| frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| row = i // cols | |
| col = i % cols | |
| x = col * tile_width + (tile_width - tile.width) // 2 | |
| y = row * tile_height + (tile_height - tile.height) // 2 | |
| tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Flash (all tiles)": | |
| max_dim = max(tile_width, tile_height) | |
| for tile in tiles: | |
| frame = Image.new("RGBA", (max_dim, max_dim), bg_rgba) | |
| x = (max_dim - tile.width) // 2 | |
| y = (max_dim - tile.height) // 2 | |
| tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Shuffle (random order)": | |
| import random | |
| indices = list(range(len(tiles))) | |
| random.shuffle(indices) | |
| revealed = set() | |
| for idx in indices: | |
| revealed.add(idx) | |
| frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| for j in revealed: | |
| row = j // cols | |
| col = j % cols | |
| x = col * tile_width + (tile_width - tiles[j].width) // 2 | |
| y = row * tile_height + (tile_height - tiles[j].height) // 2 | |
| tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| if not frames: | |
| return None | |
| # Add final complete frame | |
| final_frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| for i, tile in enumerate(tiles): | |
| row = i // cols | |
| col = i % cols | |
| x = col * tile_width + (tile_width - tile.width) // 2 | |
| y = row * tile_height + (tile_height - tile.height) // 2 | |
| tile_rgba = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| final_frame.paste(tile_rgba, (x, y)) | |
| frames.append(final_frame) | |
| temp_dir = tempfile.gettempdir() | |
| gif_path = os.path.join(temp_dir, "grid_animation.gif") | |
| return save_gif(frames, gif_path, frame_duration, bg_type, bg_color, tail_pause=True) | |
| def generate_video_from_tile_list(tiles, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type="Transparent", bg_color="#f0f0f0", transition="None"): | |
| """Generate an MP4 video from a list of tiles.""" | |
| if not tiles: | |
| return None | |
| try: | |
| import imageio.v2 as imageio | |
| import numpy as np | |
| except Exception as exc: | |
| raise gr.Error("MP4 export requires imageio and imageio-ffmpeg. Install them and restart the app.") from exc | |
| rows = int(rows) | |
| cols = int(cols) | |
| frame_duration = int(frame_duration) | |
| # Determine background color (video can't be transparent) | |
| if bg_type == "Transparent": | |
| bg_rgba = (255, 255, 255, 255) | |
| else: | |
| hex_color = bg_color.lstrip('#') | |
| r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) | |
| bg_rgba = (r, g, b, 255) | |
| tile_width = max(t.width for t in tiles) | |
| tile_height = max(t.height for t in tiles) | |
| img_width = tile_width * cols | |
| img_height = tile_height * rows | |
| frames = [] | |
| if single_tile_mode: | |
| for tile in tiles: | |
| frame = Image.new("RGBA", (tile_width, tile_height), bg_rgba) | |
| x = (tile_width - tile.width) // 2 | |
| y = (tile_height - tile.height) // 2 | |
| tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Reveal (build up)": | |
| for i in range(len(tiles) + 1): | |
| frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| for j in range(i): | |
| row = j // cols | |
| col = j % cols | |
| x = col * tile_width + (tile_width - tiles[j].width) // 2 | |
| y = row * tile_height + (tile_height - tiles[j].height) // 2 | |
| tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Cycle (one at a time)": | |
| for i, tile in enumerate(tiles): | |
| frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| row = i // cols | |
| col = i % cols | |
| x = col * tile_width + (tile_width - tile.width) // 2 | |
| y = row * tile_height + (tile_height - tile.height) // 2 | |
| tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Flash (all tiles)": | |
| max_dim = max(tile_width, tile_height) | |
| for tile in tiles: | |
| frame = Image.new("RGBA", (max_dim, max_dim), bg_rgba) | |
| x = (max_dim - tile.width) // 2 | |
| y = (max_dim - tile.height) // 2 | |
| tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Shuffle (random order)": | |
| import random | |
| indices = list(range(len(tiles))) | |
| random.shuffle(indices) | |
| revealed = set() | |
| for idx in indices: | |
| revealed.add(idx) | |
| frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| for j in revealed: | |
| row = j // cols | |
| col = j % cols | |
| x = col * tile_width + (tile_width - tiles[j].width) // 2 | |
| y = row * tile_height + (tile_height - tiles[j].height) // 2 | |
| tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] | |
| frame.paste(tile_rgba_img, (x, y)) | |
| frames.append(frame) | |
| if not frames: | |
| return None | |
| if not single_tile_mode: | |
| final_frame = Image.new("RGBA", (img_width, img_height), bg_rgba) | |
| for i, tile in enumerate(tiles): | |
| row = i // cols | |
| col = i % cols | |
| x = col * tile_width + (tile_width - tile.width) // 2 | |
| y = row * tile_height + (tile_height - tile.height) // 2 | |
| tile_rgba = tile.convert("RGBA") if tile.mode != "RGBA" else tile | |
| final_frame.paste(tile_rgba, (x, y)) | |
| frames.append(final_frame) | |
| # Normalize frames to a consistent, even size for MP4 | |
| max_w = max(f.width for f in frames) | |
| max_h = max(f.height for f in frames) | |
| if max_w % 2 == 1: | |
| max_w += 1 | |
| if max_h % 2 == 1: | |
| max_h += 1 | |
| normalized = [] | |
| for f in frames: | |
| if f.width != max_w or f.height != max_h: | |
| base = Image.new("RGBA", (max_w, max_h), bg_rgba) | |
| x = (max_w - f.width) // 2 | |
| y = (max_h - f.height) // 2 | |
| if f.mode == "RGBA": | |
| base.paste(f, (x, y), f.split()[3]) | |
| else: | |
| base.paste(f, (x, y)) | |
| f = base | |
| normalized.append(f) | |
| # Expand frames for timing and optional transitions | |
| fps = 30 | |
| base_step_frames = max(1, round((frame_duration / 1000.0) * fps)) | |
| extra_pause_frames = base_step_frames * 2 # matches the previous 3x final pause | |
| def to_rgb_array(img): | |
| if img.mode == "RGBA": | |
| flat = Image.new("RGB", img.size, bg_rgba[:3]) | |
| flat.paste(img, mask=img.split()[3]) | |
| return np.array(flat) | |
| return np.array(img.convert("RGB")) | |
| rgb_frames = [] | |
| if transition == "Fade" and len(normalized) > 1: | |
| transition_frames = min(base_step_frames, max(1, round(base_step_frames * 0.4))) | |
| hold_frames = base_step_frames - transition_frames | |
| for i in range(len(normalized) - 1): | |
| a = normalized[i] | |
| b = normalized[i + 1] | |
| a_rgb = to_rgb_array(a).astype(np.float32) | |
| b_rgb = to_rgb_array(b).astype(np.float32) | |
| for _ in range(hold_frames): | |
| rgb_frames.append(a_rgb.astype(np.uint8)) | |
| for t in range(1, transition_frames + 1): | |
| alpha = t / transition_frames | |
| blended = (a_rgb * (1 - alpha) + b_rgb * alpha).astype(np.uint8) | |
| rgb_frames.append(blended) | |
| last_rgb = to_rgb_array(normalized[-1]) | |
| for _ in range(hold_frames + extra_pause_frames): | |
| rgb_frames.append(last_rgb) | |
| else: | |
| for i, f in enumerate(normalized): | |
| repeat = base_step_frames | |
| if i == len(normalized) - 1: | |
| repeat += extra_pause_frames | |
| frame_rgb = to_rgb_array(f) | |
| for _ in range(repeat): | |
| rgb_frames.append(frame_rgb) | |
| temp_dir = tempfile.gettempdir() | |
| video_path = os.path.join(temp_dir, "grid_animation.mp4") | |
| try: | |
| writer = imageio.get_writer(video_path, format="ffmpeg", fps=fps, codec="libx264") | |
| except Exception: | |
| # Retry without codec in case ffmpeg is present but codec is unsupported | |
| writer = imageio.get_writer(video_path, format="ffmpeg", fps=fps) | |
| try: | |
| for frame in rgb_frames: | |
| writer.append_data(frame) | |
| finally: | |
| writer.close() | |
| return video_path | |
| def generate_pdf_from_tiles(tiles, rows, cols): | |
| """Generate a PDF from tiles in the same order as cut.""" | |
| if not tiles: | |
| return None | |
| rows = int(rows) | |
| cols = int(cols) | |
| # Convert all tiles to RGB for PDF compatibility | |
| rgb_tiles = [] | |
| for tile in tiles: | |
| if tile.mode == "RGBA": | |
| # Convert RGBA to RGB with white background | |
| rgb_tile = Image.new("RGB", tile.size, (255, 255, 255)) | |
| rgb_tile.paste(tile, mask=tile.split()[3]) | |
| rgb_tiles.append(rgb_tile) | |
| elif tile.mode != "RGB": | |
| rgb_tile = tile.convert("RGB") | |
| rgb_tiles.append(rgb_tile) | |
| else: | |
| rgb_tiles.append(tile) | |
| # Create PDF | |
| temp_dir = tempfile.gettempdir() | |
| pdf_path = os.path.join(temp_dir, "grid_tiles.pdf") | |
| # Save first image as PDF, then append others (high resolution for sharpness) | |
| if rgb_tiles: | |
| rgb_tiles[0].save(pdf_path, "PDF", resolution=300.0, save_all=True, append_images=rgb_tiles[1:]) | |
| return pdf_path | |
| return None | |
| def generate_gif(image, rows, cols, animation_type, single_tile_mode, frame_duration): | |
| """Generate an animated GIF from the grid tiles.""" | |
| if image is None: | |
| return None | |
| rows = int(rows) | |
| cols = int(cols) | |
| frame_duration = int(frame_duration) | |
| tiles = split_image_into_grid(image, rows, cols) | |
| img_width, img_height = image.size | |
| frames = [] | |
| if single_tile_mode: | |
| for tile in tiles: | |
| frames.append(tile.copy()) | |
| if frames: | |
| temp_dir = tempfile.gettempdir() | |
| gif_path = os.path.join(temp_dir, "grid_animation.gif") | |
| durations = [frame_duration] * len(frames) | |
| frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=durations, loop=0) | |
| return gif_path | |
| return None | |
| tile_width = img_width // cols | |
| tile_height = img_height // rows | |
| if animation_type == "Reveal (build up)": | |
| for i in range(len(tiles) + 1): | |
| frame = Image.new("RGB", (img_width, img_height), (240, 240, 240)) | |
| for j in range(i): | |
| row = j // cols | |
| col = j % cols | |
| x = col * tile_width | |
| y = row * tile_height | |
| frame.paste(tiles[j], (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Cycle (one at a time)": | |
| for i, tile in enumerate(tiles): | |
| frame = Image.new("RGB", (img_width, img_height), (240, 240, 240)) | |
| row = i // cols | |
| col = i % cols | |
| x = col * tile_width | |
| y = row * tile_height | |
| frame.paste(tile, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Flash (all tiles)": | |
| max_dim = max(tile_width, tile_height) | |
| for tile in tiles: | |
| frame = Image.new("RGB", (max_dim, max_dim), (240, 240, 240)) | |
| x = (max_dim - tile.width) // 2 | |
| y = (max_dim - tile.height) // 2 | |
| frame.paste(tile, (x, y)) | |
| frames.append(frame) | |
| elif animation_type == "Shuffle (random order)": | |
| import random | |
| indices = list(range(len(tiles))) | |
| random.shuffle(indices) | |
| revealed = set() | |
| for idx in indices: | |
| revealed.add(idx) | |
| frame = Image.new("RGB", (img_width, img_height), (240, 240, 240)) | |
| for j in revealed: | |
| row = j // cols | |
| col = j % cols | |
| x = col * tile_width | |
| y = row * tile_height | |
| frame.paste(tiles[j], (x, y)) | |
| frames.append(frame) | |
| if not frames: | |
| return None | |
| frames.append(image.copy()) | |
| temp_dir = tempfile.gettempdir() | |
| gif_path = os.path.join(temp_dir, "grid_animation.gif") | |
| durations = [frame_duration] * (len(frames) - 1) + [frame_duration * 3] | |
| frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=durations, loop=0) | |
| return gif_path | |
| def generate_video(image, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type="Transparent", bg_color="#f0f0f0", transition="None"): | |
| """Generate an MP4 video from the grid tiles.""" | |
| if image is None: | |
| return None | |
| tiles = split_image_into_grid(image, rows, cols) | |
| return generate_video_from_tile_list(tiles, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type, bg_color, transition) | |
| # CSS | |
| DEMO_IMAGE_URL = "https://cas-bridge.xethub.hf.co/xet-bridge-us/69496c121579b695d50d788c/6f3470987bf30eb4a8c0816a9d848f6c2cd4ea7a56a6fb4f147658f32539e49c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=cas%2F20260126%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260126T214749Z&X-Amz-Expires=3600&X-Amz-Signature=33c621ac642048510c067354a8d6bbfbea7249f8eb4d4e6a4db2c2cb5d673f5b&X-Amz-SignedHeaders=host&X-Xet-Cas-Uid=public&response-content-disposition=inline%3B+filename*%3DUTF-8%27%27demo.jpeg%3B+filename%3D%22demo.jpeg%22%3B&response-content-type=image%2Fjpeg&x-id=GetObject&Expires=1769467669&Policy=eyJTdGF0ZW1lbnQiOlt7IkNvbmRpdGlvbiI6eyJEYXRlTGVzc1RoYW4iOnsiQVdTOkVwb2NoVGltZSI6MTc2OTQ2NzY2OX19LCJSZXNvdXJjZSI6Imh0dHBzOi8vY2FzLWJyaWRnZS54ZXRodWIuaGYuY28veGV0LWJyaWRnZS11cy82OTQ5NmMxMjE1NzliNjk1ZDUwZDc4OGMvNmYzNDcwOTg3YmYzMGViNGE4YzA4MTZhOWQ4NDhmNmMyY2Q0ZWE3YTU2YTZmYjRmMTQ3NjU4ZjMyNTM5ZTQ5YyoifV19&Signature=VjKr0Lep9hhybTjPuu-T2tSKleCU5kEDy2X5Ljlusun4mUGqktwniL3u3S-XxelJNlNN2zRvc0tms6hxcnabzxuL7-pTsk956hC8Tg0TZRaqD5w0rqyyXqI6tXUrZ9OjXs5ltuwojFHrEkiYe0pv0Q36qyHitChVssb5mtB8N4p-kcsBl7SMTLwrxg8hULyntBc6ZNkxXEVN1k-UOBiR7D8JolxVgjiUbtfVzMDXTwUkO6nN8PapNlABj4dIry21lVrB8yPA10GYAKt2tkVjmt0aIxiux7MaM8TstBsRqX74Yjnf4a5VWCAlmO%7EwZhFW7rl2Q-M%7ET8WtM5U9vb2Rlw__&Key-Pair-Id=K2L8F4GPSG1IFC" | |
| custom_css = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); | |
| * { font-family: 'Inter', sans-serif !important; } | |
| .gradio-container { background: #f8fafc !important; max-width: 1400px !important; margin: 0 auto !important; } | |
| .header { text-align: center; padding: 1.5rem 0 1rem; } | |
| .header h1 { font-size: 1.8rem; font-weight: 700; color: #1e293b; margin: 0; } | |
| .header p { color: #64748b; margin-top: 0.25rem; } | |
| .header .demo-image { margin: 0.75rem auto 0; max-width: 720px; width: 100%; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); } | |
| .header .demo-image img { display: block; width: 100%; height: auto; border-radius: 12px; } | |
| .gr-button-primary { background: #dc2626 !important; border: none !important; font-weight: 600 !important; } | |
| .gr-button-primary:hover { background: #b91c1c !important; } | |
| footer { display: none !important; } | |
| .tile-output img { border-radius: 4px !important; border: 1px solid #e2e8f0 !important; } | |
| """ | |
| # Build UI | |
| with gr.Blocks(title="Image Grid Cutter") as demo: | |
| # State to store data between tabs | |
| stored_image = gr.State(None) | |
| stored_rows = gr.State(2) | |
| stored_cols = gr.State(2) | |
| stored_tiles = gr.State([]) # List of all tiles (can be individually edited) | |
| stored_original_tile = gr.State(None) # Original tile before crop (for reference) | |
| selected_tile_idx = gr.State(0) # Currently selected tile index | |
| stored_bg_type = gr.State("Transparent") # Background type | |
| stored_bg_color = gr.State("#f0f0f0") # Background color | |
| gr.HTML(f"<style>{custom_css}</style>") | |
| gr.HTML(f'''<div class="header"> | |
| <h1>✂️ Image Grid Cutter</h1> | |
| <p>Cut images into grids, crop tiles, create animations</p> | |
| <div class="demo-image"> | |
| <img src="{DEMO_IMAGE_URL}" alt="Demo image preview" loading="lazy" /> | |
| </div> | |
| <p><a href="https://www.airabbit.blog/how-to-create-insane-animation-with-nano-banaan-and-gpt-image-geneato-with-a-single-pormtp-and" target="_blank" style="color:#dc2626; text-decoration:none; font-weight:600;">📖 Documentation & Tutorial →</a></p> | |
| </div>''') | |
| with gr.Tabs(): | |
| # ===== TAB 1: CUT ===== | |
| with gr.Tab("1. Cut"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.HTML('<p style="font-weight:600; color:#374151;">Upload Image</p>') | |
| input_image = gr.Image(type="pil", label=None, height=250, sources=["upload", "clipboard"]) | |
| gr.HTML('<p style="font-weight:600; color:#374151; margin-top:1rem;">Grid Size</p>') | |
| with gr.Row(): | |
| rows_slider = gr.Slider(minimum=1, maximum=16, value=2, step=1, label="Rows") | |
| cols_slider = gr.Slider(minimum=1, maximum=16, value=2, step=1, label="Columns") | |
| grid_info = gr.HTML('<p style="text-align:center; color:#64748b;">2×2 = 4 pieces</p>') | |
| cut_button = gr.Button("✂️ Cut Image", variant="primary", size="lg") | |
| download_zip = gr.File(label="Download ZIP", height=60) | |
| with gr.Column(scale=2): | |
| gr.HTML('<p style="font-weight:600; color:#374151;">Preview</p>') | |
| preview_image = gr.Image(type="pil", label=None, height=450, interactive=False) | |
| gr.HTML('<p style="font-weight:600; color:#374151; margin-top:1rem;">Cut Tiles</p>') | |
| tile_outputs = [] | |
| for row_idx in range(16): | |
| with gr.Row(): | |
| for col_idx in range(16): | |
| i = row_idx * 16 + col_idx | |
| tile = gr.Image(type="pil", label=None, height=50, show_label=False, visible=(i < 4), interactive=False, elem_classes=["tile-output"]) | |
| tile_outputs.append(tile) | |
| # ===== TAB 2: CROP (Optional) ===== | |
| with gr.Tab("2. Crop (Optional)"): | |
| gr.HTML('<p style="color:#64748b; margin-bottom:1rem;">Navigate tiles, crop individually or apply to all.</p>') | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| # Background color option | |
| gr.HTML('<p style="font-weight:600; color:#374151;">Background Fill:</p>') | |
| with gr.Row(): | |
| bg_type = gr.Dropdown(choices=["Transparent", "Color"], value="Transparent", label=None, scale=1, show_label=False) | |
| bg_color = gr.ColorPicker(value="#f0f0f0", label=None, show_label=False, scale=1) | |
| # Current tile preview with navigation arrows | |
| gr.HTML('<p style="font-weight:600; color:#374151; margin-top:0.5rem;">Current Tile:</p>') | |
| with gr.Row(): | |
| prev_btn = gr.Button("◀", size="sm", scale=0, min_width=50) | |
| current_tile_preview = gr.Image(type="pil", label=None, height=150, interactive=False, show_label=False) | |
| next_btn = gr.Button("▶", size="sm", scale=0, min_width=50) | |
| # Navigation info + first/last | |
| selected_info = gr.HTML('<p style="text-align:center; color:#374151; font-weight:600;">Tile 1 of 1</p>') | |
| with gr.Row(): | |
| first_btn = gr.Button("⏮ First", size="sm") | |
| last_btn = gr.Button("Last ⏭", size="sm") | |
| gr.HTML('<p style="font-weight:600; color:#374151; margin-top:1rem;">Crop Tool</p>') | |
| crop_editor = gr.ImageEditor( | |
| type="pil", | |
| label=None, | |
| height=350, | |
| sources=["upload"], | |
| transforms=["crop"], | |
| brush=False, | |
| eraser=False, | |
| interactive=True | |
| ) | |
| with gr.Row(): | |
| save_tile_btn = gr.Button("💾 Save This Tile", variant="secondary", size="lg") | |
| apply_crop_btn = gr.Button("✅ Apply to All", variant="primary", size="lg") | |
| cropped_zip = gr.File(label="Download All Tiles (ZIP)", height=60) | |
| with gr.Column(scale=2): | |
| gr.HTML('<p style="font-weight:600; color:#374151;">All Tiles (after edits)</p>') | |
| cropped_outputs = [] | |
| for row_idx in range(16): | |
| with gr.Row(): | |
| for col_idx in range(16): | |
| i = row_idx * 16 + col_idx | |
| tile = gr.Image(type="pil", label=None, height=50, show_label=False, visible=(i < 4), interactive=False) | |
| cropped_outputs.append(tile) | |
| # ===== TAB 3: ANIMATION ===== | |
| with gr.Tab("3. Export"): | |
| gr.HTML('<p style="color:#64748b; margin-bottom:1rem;">Generate animated GIF/MP4 or export tiles as PDF.</p>') | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| animation_type = gr.Dropdown( | |
| choices=["Reveal (build up)", "Cycle (one at a time)", "Flash (all tiles)", "Shuffle (random order)"], | |
| value="Reveal (build up)", | |
| label="Animation Style" | |
| ) | |
| single_tile_mode = gr.Checkbox(label="Single tile per frame (slideshow)", value=True) | |
| frame_duration = gr.Slider(minimum=50, maximum=5000, value=200, step=50, label="Frame Duration (ms)") | |
| gif_button = gr.Button("🎬 Generate GIF", variant="primary", size="lg") | |
| gif_file = gr.File(label="Download GIF", height=60) | |
| video_transition = gr.Dropdown( | |
| choices=["None", "Fade"], | |
| value="None", | |
| label="Video Transition" | |
| ) | |
| video_button = gr.Button("🎥 Generate MP4", variant="primary", size="lg") | |
| video_file = gr.File(label="Download MP4", height=60) | |
| gr.HTML('<hr style="margin:1.5rem 0; border:none; border-top:1px solid #e2e8f0;">') | |
| pdf_button = gr.Button("📄 Export PDF", variant="primary", size="lg") | |
| pdf_file = gr.File(label="Download PDF", height=60) | |
| with gr.Column(scale=2): | |
| gr.HTML('<p style="font-weight:600; color:#374151;">Preview will use current tiles</p>') | |
| # ===== EVENT HANDLERS ===== | |
| def update_preview(image, rows, cols): | |
| rows = int(rows) | |
| cols = int(cols) | |
| total = rows * cols | |
| preview = create_grid_preview(image, rows, cols) if image else None | |
| visibility = [gr.update(visible=(i < total)) for i in range(256)] | |
| info = f'<p style="text-align:center; color:#64748b;">{rows}×{cols} = {total} pieces</p>' | |
| return [preview, info] + visibility | |
| input_image.change(fn=update_preview, inputs=[input_image, rows_slider, cols_slider], outputs=[preview_image, grid_info] + tile_outputs) | |
| rows_slider.change(fn=update_preview, inputs=[input_image, rows_slider, cols_slider], outputs=[preview_image, grid_info] + tile_outputs) | |
| cols_slider.change(fn=update_preview, inputs=[input_image, rows_slider, cols_slider], outputs=[preview_image, grid_info] + tile_outputs) | |
| def do_cut(image, rows, cols): | |
| tiles_list, zip_path, first_tile = process_image(image, rows, cols) | |
| rows = int(rows) | |
| cols = int(cols) | |
| total = rows * cols | |
| # Create tile list (non-None tiles only) | |
| actual_tiles = [t for t in tiles_list[:total] if t is not None] | |
| # Convert first_tile to dict format for ImageEditor | |
| editor_value = {"background": first_tile, "layers": [], "composite": None} if first_tile else None | |
| # Create updates with visibility for Cut tab | |
| tile_updates = [gr.update(value=tiles_list[i] if i < len(tiles_list) else None, visible=(i < total)) for i in range(256)] | |
| # Create updates for Crop tab tiles | |
| crop_updates = [gr.update(value=tiles_list[i] if i < len(tiles_list) else None, visible=(i < total)) for i in range(256)] | |
| # Info text | |
| info_text = f'<p style="text-align:center; color:#374151; font-weight:600;">Tile 1 of {total} (R1-C1)</p>' | |
| return tile_updates + crop_updates + [zip_path, editor_value, first_tile, first_tile, image, rows, cols, actual_tiles, info_text, 0] | |
| cut_button.click( | |
| fn=do_cut, | |
| inputs=[input_image, rows_slider, cols_slider], | |
| outputs=tile_outputs + cropped_outputs + [download_zip, crop_editor, stored_original_tile, current_tile_preview, stored_image, stored_rows, stored_cols, stored_tiles, selected_info, selected_tile_idx] | |
| ) | |
| def navigate_tile(tiles, current_idx, rows, cols, direction): | |
| """Navigate to prev/next tile.""" | |
| if not tiles: | |
| return None, None, '<p style="text-align:center; color:#64748b;">No tiles</p>', 0 | |
| total = len(tiles) | |
| new_idx = current_idx + direction | |
| # Wrap around | |
| if new_idx < 0: | |
| new_idx = total - 1 | |
| elif new_idx >= total: | |
| new_idx = 0 | |
| tile = tiles[new_idx] | |
| editor_value = {"background": tile, "layers": [], "composite": None} | |
| row_num = new_idx // int(cols) + 1 | |
| col_num = new_idx % int(cols) + 1 | |
| info = f'<p style="text-align:center; color:#374151; font-weight:600;">Tile {new_idx + 1} of {total} (R{row_num}-C{col_num})</p>' | |
| return tile, editor_value, info, new_idx | |
| prev_btn.click( | |
| fn=lambda tiles, idx, rows, cols: navigate_tile(tiles, idx, rows, cols, -1), | |
| inputs=[stored_tiles, selected_tile_idx, stored_rows, stored_cols], | |
| outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] | |
| ) | |
| next_btn.click( | |
| fn=lambda tiles, idx, rows, cols: navigate_tile(tiles, idx, rows, cols, 1), | |
| inputs=[stored_tiles, selected_tile_idx, stored_rows, stored_cols], | |
| outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] | |
| ) | |
| def jump_to_tile(tiles, rows, cols, target_idx): | |
| """Jump to first or last tile.""" | |
| if not tiles: | |
| return None, None, '<p style="text-align:center; color:#64748b;">No tiles</p>', 0 | |
| total = len(tiles) | |
| idx = max(0, min(target_idx, total - 1)) | |
| tile = tiles[idx] | |
| editor_value = {"background": tile, "layers": [], "composite": None} | |
| row_num = idx // int(cols) + 1 | |
| col_num = idx % int(cols) + 1 | |
| info = f'<p style="text-align:center; color:#374151; font-weight:600;">Tile {idx + 1} of {total} (R{row_num}-C{col_num})</p>' | |
| return tile, editor_value, info, idx | |
| first_btn.click( | |
| fn=lambda tiles, rows, cols: jump_to_tile(tiles, rows, cols, 0), | |
| inputs=[stored_tiles, stored_rows, stored_cols], | |
| outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] | |
| ) | |
| last_btn.click( | |
| fn=lambda tiles, rows, cols: jump_to_tile(tiles, rows, cols, len(tiles) - 1 if tiles else 0), | |
| inputs=[stored_tiles, stored_rows, stored_cols], | |
| outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] | |
| ) | |
| def save_single_tile(tiles, idx, cropped_editor, rows, cols, bg_type_val, bg_color_val): | |
| """Save the cropped tile back to the tiles list.""" | |
| if not tiles or cropped_editor is None: | |
| print(f"save_single_tile: No tiles or editor") | |
| return [gr.update()] * 256 + [tiles, None, None] | |
| idx = int(idx) | |
| rows = int(rows) | |
| cols = int(cols) | |
| total = rows * cols | |
| # Get cropped image | |
| cropped_img = get_cropped_image(cropped_editor) | |
| if cropped_img is None: | |
| print(f"save_single_tile: Could not get cropped image from editor") | |
| return [gr.update()] * 256 + [tiles, None, None] | |
| # Get max dimensions from all tiles | |
| max_w = max(t.width for t in tiles) | |
| max_h = max(t.height for t in tiles) | |
| print(f"save_single_tile: Saving tile {idx}, original size={tiles[idx].size}, cropped size={cropped_img.size}, bg={bg_type_val}") | |
| # Determine background color | |
| if bg_type_val == "Transparent": | |
| bg_rgba = (0, 0, 0, 0) | |
| mode = "RGBA" | |
| else: | |
| # Parse hex color | |
| hex_color = bg_color_val.lstrip('#') | |
| r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) | |
| bg_rgba = (r, g, b, 255) | |
| mode = "RGBA" | |
| # Create normalized version (padded with background to match max size) | |
| normalized = Image.new(mode, (max_w, max_h), bg_rgba) | |
| x = (max_w - cropped_img.width) // 2 | |
| y = (max_h - cropped_img.height) // 2 | |
| # Convert cropped to RGBA if needed | |
| if cropped_img.mode != "RGBA": | |
| cropped_img = cropped_img.convert("RGBA") | |
| normalized.paste(cropped_img, (x, y)) | |
| # Update tile in list with normalized version | |
| new_tiles = list(tiles) | |
| if 0 <= idx < len(new_tiles): | |
| new_tiles[idx] = normalized | |
| # Debug: print all sizes after update | |
| sizes = [f"{t.size}" for t in new_tiles] | |
| print(f"save_single_tile: After update (normalized), sizes={sizes}") | |
| # Create updates for display | |
| updates = [gr.update(value=new_tiles[i] if i < len(new_tiles) else None, visible=(i < total)) for i in range(256)] | |
| # Create zip | |
| zip_path = create_tiles_zip(new_tiles, cols) | |
| # Update preview to show normalized tile | |
| return updates + [new_tiles, normalized, zip_path, bg_type_val, bg_color_val] | |
| save_tile_btn.click( | |
| fn=save_single_tile, | |
| inputs=[stored_tiles, selected_tile_idx, crop_editor, stored_rows, stored_cols, bg_type, bg_color], | |
| outputs=cropped_outputs + [stored_tiles, current_tile_preview, cropped_zip, stored_bg_type, stored_bg_color] | |
| ) | |
| def apply_crop_to_all_tiles(tiles, cropped_editor, orig_tile, rows, cols, current_idx): | |
| """Apply the same crop to all tiles.""" | |
| if not tiles or cropped_editor is None or orig_tile is None: | |
| return [gr.update()] * 256 + [tiles, None, None] | |
| rows = int(rows) | |
| cols = int(cols) | |
| total = rows * cols | |
| current_idx = int(current_idx) | |
| cropped_img = get_cropped_image(cropped_editor) | |
| if cropped_img is None: | |
| return [gr.update()] * 256 + [tiles, None, None] | |
| orig_w, orig_h = orig_tile.size | |
| crop_w, crop_h = cropped_img.size | |
| # If same size, no crop | |
| if orig_w == crop_w and orig_h == crop_h: | |
| updates = [gr.update(value=tiles[i] if i < len(tiles) else None, visible=(i < total)) for i in range(256)] | |
| zip_path = create_tiles_zip(tiles, cols) | |
| preview = tiles[current_idx] if current_idx < len(tiles) else None | |
| return updates + [tiles, preview, zip_path] | |
| # Calculate crop margins | |
| left_crop = (orig_w - crop_w) // 2 | |
| top_crop = (orig_h - crop_h) // 2 | |
| right_crop = orig_w - crop_w - left_crop | |
| bottom_crop = orig_h - crop_h - top_crop | |
| # Apply crop to all tiles | |
| new_tiles = [] | |
| for tile in tiles: | |
| t_w, t_h = tile.size | |
| new_left = min(left_crop, t_w - 1) | |
| new_top = min(top_crop, t_h - 1) | |
| new_right = max(new_left + 1, t_w - right_crop) | |
| new_bottom = max(new_top + 1, t_h - bottom_crop) | |
| if new_right > new_left and new_bottom > new_top: | |
| cropped = tile.crop((new_left, new_top, new_right, new_bottom)) | |
| else: | |
| cropped = tile | |
| new_tiles.append(cropped) | |
| updates = [gr.update(value=new_tiles[i] if i < len(new_tiles) else None, visible=(i < total)) for i in range(256)] | |
| zip_path = create_tiles_zip(new_tiles, cols) | |
| # Update preview with current tile | |
| preview = new_tiles[current_idx] if current_idx < len(new_tiles) else None | |
| return updates + [new_tiles, preview, zip_path] | |
| apply_crop_btn.click( | |
| fn=apply_crop_to_all_tiles, | |
| inputs=[stored_tiles, crop_editor, stored_original_tile, stored_rows, stored_cols, selected_tile_idx], | |
| outputs=cropped_outputs + [stored_tiles, current_tile_preview, cropped_zip] | |
| ) | |
| def generate_gif_from_tiles(image, rows, cols, tiles, animation_type, single_tile_mode, frame_duration, bg_type_val, bg_color_val): | |
| """Generate GIF using edited tiles if available, otherwise from image.""" | |
| if tiles and len(tiles) > 0: | |
| # Debug: print tile sizes | |
| sizes = [f"{t.size}" for t in tiles] | |
| print(f"GIF tiles sizes: {sizes}, bg={bg_type_val}") | |
| # Use edited tiles | |
| return generate_gif_from_tile_list(tiles, int(rows), int(cols), animation_type, single_tile_mode, int(frame_duration), bg_type_val, bg_color_val) | |
| else: | |
| # Fall back to original image | |
| return generate_gif(image, rows, cols, animation_type, single_tile_mode, frame_duration) | |
| gif_button.click( | |
| fn=generate_gif_from_tiles, | |
| inputs=[stored_image, stored_rows, stored_cols, stored_tiles, animation_type, single_tile_mode, frame_duration, stored_bg_type, stored_bg_color], | |
| outputs=[gif_file] | |
| ) | |
| def generate_video_from_tiles(image, rows, cols, tiles, animation_type, single_tile_mode, frame_duration, bg_type_val, bg_color_val, transition_val): | |
| """Generate MP4 using edited tiles if available, otherwise from image.""" | |
| if tiles and len(tiles) > 0: | |
| return generate_video_from_tile_list(tiles, int(rows), int(cols), animation_type, single_tile_mode, int(frame_duration), bg_type_val, bg_color_val, transition_val) | |
| return generate_video(image, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type_val, bg_color_val, transition_val) | |
| video_button.click( | |
| fn=generate_video_from_tiles, | |
| inputs=[stored_image, stored_rows, stored_cols, stored_tiles, animation_type, single_tile_mode, frame_duration, stored_bg_type, stored_bg_color, video_transition], | |
| outputs=[video_file] | |
| ) | |
| def generate_pdf(tiles, rows, cols): | |
| """Generate PDF from stored tiles.""" | |
| if tiles and len(tiles) > 0: | |
| return generate_pdf_from_tiles(tiles, rows, cols) | |
| return None | |
| pdf_button.click( | |
| fn=generate_pdf, | |
| inputs=[stored_tiles, stored_rows, stored_cols], | |
| outputs=[pdf_file] | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| show_error=True, | |
| server_name=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"), | |
| ssr_mode=False, | |
| ) | |