From f518e1751bfa23af36f0a08db5751cf8a002af40 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sat, 3 Jan 2026 20:08:16 +0000 Subject: [PATCH] IMage genartion service and models revamp - #Migration Runs --- backend/igny8_core/ai/ai_core.py | 53 ++- .../ai/functions/generate_images.py | 2 - backend/igny8_core/ai/tasks.py | 54 ++- backend/igny8_core/business/content/models.py | 23 ++ .../migrations/0024_update_image_models_v2.py | 113 ++++++ .../modules/system/global_settings_models.py | 28 +- .../migrations/0014_update_runware_models.py | 43 +++ .../0016_images_unique_position_constraint.py | 23 ++ .../igny8_core/modules/writer/serializers.py | 4 +- .../plans/IMAGE_MODELS_IMPLEMENTATION_PLAN.md | 343 ++++++++++++++++++ .../src/components/common/ImageQueueModal.tsx | 2 +- frontend/src/pages/Settings/Integration.tsx | 122 ++----- frontend/src/pages/Sites/Settings.tsx | 73 ++-- frontend/src/services/api.ts | 1 + .../src/templates/ContentViewTemplate.tsx | 220 +++++------ 15 files changed, 817 insertions(+), 287 deletions(-) create mode 100644 backend/igny8_core/modules/billing/migrations/0024_update_image_models_v2.py create mode 100644 backend/igny8_core/modules/system/migrations/0014_update_runware_models.py create mode 100644 backend/igny8_core/modules/writer/migrations/0016_images_unique_position_constraint.py create mode 100644 docs/plans/IMAGE_MODELS_IMPLEMENTATION_PLAN.md diff --git a/backend/igny8_core/ai/ai_core.py b/backend/igny8_core/ai/ai_core.py index 8ec7744b..04d523cc 100644 --- a/backend/igny8_core/ai/ai_core.py +++ b/backend/igny8_core/ai/ai_core.py @@ -982,24 +982,51 @@ class AICore: # Runware uses array payload with authentication task first, then imageInference # Reference: image-generation.php lines 79-97 import uuid + + # Build base inference task + inference_task = { + 'taskType': 'imageInference', + 'taskUUID': str(uuid.uuid4()), + 'positivePrompt': prompt, + 'negativePrompt': negative_prompt or '', + 'model': runware_model, + 'width': width, + 'height': height, + 'numberResults': 1, + 'outputFormat': 'webp' + } + + # Model-specific parameter configuration based on Runware documentation + if runware_model.startswith('bria:'): + # Bria 3.2 (bria:10@1) - Commercial-ready, steps 4-10 (default 8) + inference_task['steps'] = 8 + # Bria provider settings for enhanced quality + inference_task['providerSettings'] = { + 'bria': { + 'promptEnhancement': True, + 'enhanceImage': True, + 'medium': 'photography', + 'contentModeration': True + } + } + print(f"[AI][{function_name}] Using Bria 3.2 config: steps=8, providerSettings enabled") + elif runware_model.startswith('google:'): + # Nano Banana (google:4@2) - Premium quality, no explicit steps needed + # Google models handle steps internally + inference_task['resolution'] = '1k' # Use 1K tier for optimal speed/quality + print(f"[AI][{function_name}] Using Nano Banana config: resolution=1k") + else: + # Hi Dream Full (runware:97@1) - General diffusion, steps 20, CFGScale 7 + inference_task['steps'] = 20 + inference_task['CFGScale'] = 7 + print(f"[AI][{function_name}] Using Hi Dream Full config: steps=20, CFGScale=7") + payload = [ { 'taskType': 'authentication', 'apiKey': api_key }, - { - 'taskType': 'imageInference', - 'taskUUID': str(uuid.uuid4()), - 'positivePrompt': prompt, - 'negativePrompt': negative_prompt or '', - 'model': runware_model, - 'width': width, - 'height': height, - 'steps': 30, - 'CFGScale': 7.5, - 'numberResults': 1, - 'outputFormat': 'webp' - } + inference_task ] request_start = time.time() diff --git a/backend/igny8_core/ai/functions/generate_images.py b/backend/igny8_core/ai/functions/generate_images.py index 05f3c17b..b548708a 100644 --- a/backend/igny8_core/ai/functions/generate_images.py +++ b/backend/igny8_core/ai/functions/generate_images.py @@ -101,8 +101,6 @@ class GenerateImagesFunction(BaseAIFunction): 'model': model, 'image_type': image_settings.get('image_type') or global_settings.image_style, 'max_in_article_images': int(image_settings.get('max_in_article_images') or global_settings.max_in_article_images), - 'desktop_enabled': image_settings.get('desktop_enabled', True), - 'mobile_enabled': image_settings.get('mobile_enabled', True), } def build_prompt(self, data: Dict, account=None) -> Dict: diff --git a/backend/igny8_core/ai/tasks.py b/backend/igny8_core/ai/tasks.py index bc27882b..8574c99b 100644 --- a/backend/igny8_core/ai/tasks.py +++ b/backend/igny8_core/ai/tasks.py @@ -217,20 +217,32 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings") image_type = config.get('image_type') or global_settings.image_style image_format = config.get('image_format', 'webp') - desktop_enabled = config.get('desktop_enabled', True) - mobile_enabled = config.get('mobile_enabled', True) - # Get image sizes from config, with fallback defaults - featured_image_size = config.get('featured_image_size') or ('1280x832' if provider == 'runware' else '1024x1024') - desktop_image_size = config.get('desktop_image_size') or global_settings.desktop_image_size - in_article_image_size = config.get('in_article_image_size') or '512x512' # Default to 512x512 + + # Model-specific landscape sizes (square is always 1024x1024) + # Based on Runware documentation for optimal results per model + MODEL_LANDSCAPE_SIZES = { + 'runware:97@1': '1280x768', # Hi Dream Full landscape + 'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9) + 'google:4@2': '1376x768', # Nano Banana landscape (16:9) + } + DEFAULT_SQUARE_SIZE = '1024x1024' + + # Get model-specific landscape size for featured images + model_landscape_size = MODEL_LANDSCAPE_SIZES.get(model, '1280x768') + + # Featured image always uses model-specific landscape size + featured_image_size = model_landscape_size + # In-article images: alternating square/landscape based on position (handled in image loop) + in_article_square_size = DEFAULT_SQUARE_SIZE + in_article_landscape_size = model_landscape_size logger.info(f"[process_image_generation_queue] Settings loaded:") logger.info(f" - Provider: {provider}") logger.info(f" - Model: {model}") logger.info(f" - Image type: {image_type}") logger.info(f" - Image format: {image_format}") - logger.info(f" - Desktop enabled: {desktop_enabled}") - logger.info(f" - Mobile enabled: {mobile_enabled}") + logger.info(f" - Featured image size: {featured_image_size}") + logger.info(f" - In-article square: {in_article_square_size}, landscape: {in_article_landscape_size}") # Get provider API key # API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys) @@ -478,15 +490,25 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None } ) - # Use appropriate size based on image type + # Use appropriate size based on image type and position + # Featured: Always landscape (model-specific) + # In-article: Alternating square/landscape based on position + # Position 0: Square (1024x1024) + # Position 1: Landscape (model-specific) + # Position 2: Square (1024x1024) + # Position 3: Landscape (model-specific) if image.image_type == 'featured': - image_size = featured_image_size # Read from config - elif image.image_type == 'desktop': - image_size = desktop_image_size - elif image.image_type == 'mobile': - image_size = '512x512' # Fixed mobile size - else: # in_article or other - image_size = in_article_image_size # Read from config, default 512x512 + image_size = featured_image_size # Model-specific landscape + elif image.image_type == 'in_article': + # Alternate based on position: even=square, odd=landscape + position = image.position or 0 + if position % 2 == 0: # Position 0, 2: Square + image_size = in_article_square_size + else: # Position 1, 3: Landscape + image_size = in_article_landscape_size + logger.info(f"[process_image_generation_queue] In-article image position {position}: using {'square' if position % 2 == 0 else 'landscape'} size {image_size}") + else: # desktop or other (legacy) + image_size = in_article_square_size # Default to square result = ai_core.generate_image( prompt=formatted_prompt, diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py index 30be9e8c..e49df013 100644 --- a/backend/igny8_core/business/content/models.py +++ b/backend/igny8_core/business/content/models.py @@ -568,10 +568,33 @@ class Images(SoftDeletableModel, SiteSectorBaseModel): models.Index(fields=['content', 'position']), models.Index(fields=['task', 'position']), ] + # Ensure unique position per content+image_type combination + constraints = [ + models.UniqueConstraint( + fields=['content', 'image_type', 'position'], + name='unique_content_image_type_position', + condition=models.Q(is_deleted=False) + ), + ] objects = SoftDeleteManager() all_objects = models.Manager() + @property + def aspect_ratio(self): + """ + Determine aspect ratio based on position for layout rendering. + Position 0, 2: square (1:1) + Position 1, 3: landscape (16:9 or similar) + Featured: always landscape + """ + if self.image_type == 'featured': + return 'landscape' + elif self.image_type == 'in_article': + # Even positions are square, odd positions are landscape + return 'square' if (self.position or 0) % 2 == 0 else 'landscape' + return 'square' # Default + def save(self, *args, **kwargs): """Track image usage when creating new images""" is_new = self.pk is None diff --git a/backend/igny8_core/modules/billing/migrations/0024_update_image_models_v2.py b/backend/igny8_core/modules/billing/migrations/0024_update_image_models_v2.py new file mode 100644 index 00000000..c2e5d3dc --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0024_update_image_models_v2.py @@ -0,0 +1,113 @@ +""" +Migration: Update Runware/Image model configurations for new model structure + +This migration: +1. Updates runware:97@1 to "Hi Dream Full - Basic" +2. Adds Bria 3.2 model as bria:10@1 (correct AIR ID) +3. Adds Nano Banana (Google) as google:4@2 (Premium tier) +4. Removes old civitai model reference +5. Adds one_liner_description field values +""" +from decimal import Decimal +from django.db import migrations + + +def update_image_models(apps, schema_editor): + """Update image models in AIModelConfig""" + AIModelConfig = apps.get_model('billing', 'AIModelConfig') + + # Update existing runware:97@1 model + AIModelConfig.objects.update_or_create( + model_name='runware:97@1', + defaults={ + 'display_name': 'Hi Dream Full - Basic', + 'model_type': 'image', + 'provider': 'runware', + 'cost_per_image': Decimal('0.006'), # Basic tier, cheaper + 'valid_sizes': ['1024x1024', '1280x768', '768x1280'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': True, + 'sort_order': 10, + 'description': 'Fast & affordable image generation. Steps: 20, CFG: 7. Good for quick iterations.', + } + ) + + # Add Bria 3.2 model with correct AIR ID + AIModelConfig.objects.update_or_create( + model_name='bria:10@1', + defaults={ + 'display_name': 'Bria 3.2 - Quality', + 'model_type': 'image', + 'provider': 'runware', # Via Runware API + 'cost_per_image': Decimal('0.010'), # Quality tier + 'valid_sizes': ['1024x1024', '1344x768', '768x1344', '1216x832', '832x1216'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': False, + 'sort_order': 11, + 'description': 'Commercial-safe AI. Steps: 8, prompt enhancement enabled. Licensed training data.', + } + ) + + # Add Nano Banana (Google) Premium model + AIModelConfig.objects.update_or_create( + model_name='google:4@2', + defaults={ + 'display_name': 'Nano Banana - Premium', + 'model_type': 'image', + 'provider': 'runware', # Via Runware API + 'cost_per_image': Decimal('0.015'), # Premium tier + 'valid_sizes': ['1024x1024', '1376x768', '768x1376', '1264x848', '848x1264'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': False, + 'sort_order': 12, + 'description': 'Google Gemini 3 Pro. Best quality, text rendering, advanced reasoning. Premium pricing.', + } + ) + + # Deactivate old civitai model (replaced by correct bria:10@1) + AIModelConfig.objects.filter( + model_name='civitai:618692@691639' + ).update(is_active=False) + + # Deactivate other old models + AIModelConfig.objects.filter( + model_name__in=['runware:100@1', 'runware:101@1'] + ).update(is_active=False) + + +def reverse_migration(apps, schema_editor): + """Reverse the migration""" + AIModelConfig = apps.get_model('billing', 'AIModelConfig') + + # Restore old display names + AIModelConfig.objects.filter(model_name='runware:97@1').update( + display_name='Hi Dream Full - Standard', + ) + + # Remove new models + AIModelConfig.objects.filter(model_name__in=['bria:10@1', 'google:4@2']).delete() + + # Re-activate old models + AIModelConfig.objects.filter( + model_name__in=['runware:100@1', 'runware:101@1', 'civitai:618692@691639'] + ).update(is_active=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0023_update_runware_models'), + ] + + operations = [ + migrations.RunPython(update_image_models, reverse_migration), + ] diff --git a/backend/igny8_core/modules/system/global_settings_models.py b/backend/igny8_core/modules/system/global_settings_models.py index e859962d..e280e3c9 100644 --- a/backend/igny8_core/modules/system/global_settings_models.py +++ b/backend/igny8_core/modules/system/global_settings_models.py @@ -63,8 +63,9 @@ def get_image_model_choices(provider=None): ] elif provider == 'runware': return [ - ('runware:97@1', 'Hi Dream Full - Standard'), - ('civitai:618692@691639', 'Bria 3.2 - Premium'), + ('runware:97@1', 'Hi Dream Full - Basic'), + ('bria:10@1', 'Bria 3.2 - Quality'), + ('google:4@2', 'Nano Banana - Premium'), ] return [] @@ -171,10 +172,21 @@ class GlobalIntegrationSettings(models.Model): ] RUNWARE_MODEL_CHOICES = [ - ('runware:97@1', 'Hi Dream Full - Standard'), - ('civitai:618692@691639', 'Bria 3.2 - Premium'), + ('runware:97@1', 'Hi Dream Full - Basic'), + ('bria:10@1', 'Bria 3.2 - Quality'), + ('google:4@2', 'Nano Banana - Premium'), ] + # Model-specific landscape sizes (square is always 1024x1024 for all models) + MODEL_LANDSCAPE_SIZES = { + 'runware:97@1': '1280x768', # Hi Dream Full landscape + 'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9) + 'google:4@2': '1376x768', # Nano Banana landscape (16:9) + } + + # Default square size (universal across all models) + DEFAULT_SQUARE_SIZE = '1024x1024' + BRIA_MODEL_CHOICES = [ ('bria-2.3', 'Bria 2.3 - High Quality ($0.015/image)'), ('bria-2.3-fast', 'Bria 2.3 Fast - Quick Generation ($0.010/image)'), @@ -335,13 +347,9 @@ class GlobalIntegrationSettings(models.Model): desktop_image_size = models.CharField( max_length=20, default='1024x1024', - help_text="Default desktop image size (accounts can override if plan allows)" - ) - mobile_image_size = models.CharField( - max_length=20, - default='512x512', - help_text="Default mobile image size (accounts can override if plan allows)" + help_text="Default image size for in-article images (accounts can override if plan allows)" ) + # Note: mobile_image_size removed - no longer needed # Metadata is_active = models.BooleanField(default=True) diff --git a/backend/igny8_core/modules/system/migrations/0014_update_runware_models.py b/backend/igny8_core/modules/system/migrations/0014_update_runware_models.py new file mode 100644 index 00000000..25558731 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0014_update_runware_models.py @@ -0,0 +1,43 @@ +# Generated migration for updating Runware image models + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0013_add_anthropic_integration'), + ] + + operations = [ + # Update runware_model field with new model choices + migrations.AlterField( + model_name='globalintegrationsettings', + name='runware_model', + field=models.CharField( + choices=[ + ('runware:97@1', 'Hi Dream Full - Basic'), + ('bria:10@1', 'Bria 3.2 - Quality'), + ('google:4@2', 'Nano Banana - Premium'), + ], + default='runware:97@1', + help_text='Default Runware model (accounts can override if plan allows)', + max_length=100, + ), + ), + # Update desktop_image_size help text (mobile removed) + migrations.AlterField( + model_name='globalintegrationsettings', + name='desktop_image_size', + field=models.CharField( + default='1024x1024', + help_text='Default image size for in-article images (accounts can override if plan allows)', + max_length=20, + ), + ), + # Remove mobile_image_size field if it exists (safe removal) + migrations.RemoveField( + model_name='globalintegrationsettings', + name='mobile_image_size', + ), + ] diff --git a/backend/igny8_core/modules/writer/migrations/0016_images_unique_position_constraint.py b/backend/igny8_core/modules/writer/migrations/0016_images_unique_position_constraint.py new file mode 100644 index 00000000..d80ad8e1 --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0016_images_unique_position_constraint.py @@ -0,0 +1,23 @@ +# Generated migration for Images model unique constraint + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('writer', '0015_add_publishing_scheduler_fields'), + ] + + operations = [ + # Add unique constraint for content + image_type + position + # This ensures no duplicate positions for the same image type within a content + migrations.AddConstraint( + model_name='images', + constraint=models.UniqueConstraint( + condition=models.Q(('is_deleted', False)), + fields=('content', 'image_type', 'position'), + name='unique_content_image_type_position', + ), + ), + ] diff --git a/backend/igny8_core/modules/writer/serializers.py b/backend/igny8_core/modules/writer/serializers.py index a5e07b5e..03bb9236 100644 --- a/backend/igny8_core/modules/writer/serializers.py +++ b/backend/igny8_core/modules/writer/serializers.py @@ -76,6 +76,7 @@ class ImagesSerializer(serializers.ModelSerializer): """Serializer for Images model""" task_title = serializers.SerializerMethodField() content_title = serializers.SerializerMethodField() + aspect_ratio = serializers.ReadOnlyField() # Expose aspect_ratio property class Meta: model = Images @@ -92,11 +93,12 @@ class ImagesSerializer(serializers.ModelSerializer): 'caption', 'status', 'position', + 'aspect_ratio', 'created_at', 'updated_at', 'account_id', ] - read_only_fields = ['id', 'created_at', 'updated_at', 'account_id'] + read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'aspect_ratio'] def get_task_title(self, obj): """Get task title""" diff --git a/docs/plans/IMAGE_MODELS_IMPLEMENTATION_PLAN.md b/docs/plans/IMAGE_MODELS_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..a1c5b7f8 --- /dev/null +++ b/docs/plans/IMAGE_MODELS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,343 @@ +# 📋 COMPREHENSIVE IMAGE MODELS IMPLEMENTATION PLAN + +## Model Reference Summary + +| Model | AIR ID | Tier | Supported Dimensions (Square/Landscape) | +|-------|--------|------|----------------------------------------| +| **Hi Dream Full** | `runware:97@1` | Basic/Cheap | 1024×1024, 1280×768 (general diffusion) | +| **Bria 3.2** | `bria:10@1` | Good Quality | 1024×1024, 1344×768 (16:9) or 1216×832 (3:2) | +| **Nano Banana** | `google:4@2` | Premium | 1024×1024, 1376×768 (16:9) or 1264×848 (3:2) | + +--- + +## TASK 1: Image Generation Progress Modal Width Increase + +**Files to modify:** +- ImageQueueModal.tsx + +**Changes:** +1. Locate the modal container `max-w-*` or width class +2. Increase width by 50% (e.g., `max-w-2xl` → `max-w-4xl`, or explicit width) +3. Ensure responsive behavior is maintained for smaller screens + +--- + +## TASK 2: Fix Duplicate In-Article Image Names (Unique Field Storage) + +**Files to modify:** +- Backend: models.py (Images model) +- Backend: generate_images.py +- Backend: ai_processor.py + +**Issue:** First 2 in-article images may have duplicate field names causing overwrite + +**Changes:** +1. Ensure `position` field is properly enforced (0, 1, 2, 3) for in-article images +2. Update image creation logic to use unique combination: `content_id + image_type + position` +3. Add validation to prevent duplicate position values for same content +4. Ensure image storage generates unique filenames (timestamp + uuid + position) + +--- + +## TASK 3: Image Settings Configuration - Remove Mobile Options + +**Files to modify:** +- Frontend: Settings.tsx (Site Settings) +- Frontend: Integration.tsx +- Backend: global_settings_models.py + +**Changes:** +1. Remove `mobile_enabled` option from settings UI +2. Remove `mobile_image_size` option +3. Remove `IMAGE_TYPE_CHOICES` → `mobile` option +4. Clean up related state and form fields +5. Update `ImageSettings` interface to remove mobile fields + +--- + +## TASK 4: Fixed Image Sizes Configuration + +**Specification:** +- **Featured Image:** Fixed size (use primary model's best landscape dimension) +- **In-Article Images:** + - 2 × Square: `1024×1024` + - 2 × Landscape: `1280×768` (for Hi Dream) / `1344×768` (for Bria/Nano Banana) + +**Files to modify:** +- Backend: global_settings_models.py +- Backend: ai_core.py +- Backend: AI functions for image generation + +**Changes:** +1. Add constants for fixed sizes: + ``` + FEATURED_SIZE = "1792x1024" (landscape, prominent) + SQUARE_SIZE = "1024x1024" + LANDSCAPE_SIZE = model-dependent (see model config) + ``` +2. Remove user-selectable size options where fixed +3. Update global settings with fixed defaults + +--- + +## TASK 5: Update AI Function Calls - Alternating Square/Landscape Pattern + +**Files to modify:** +- Backend: generate_images.py +- Backend: ai_core.py (generate_image method) + +**Pattern:** Request 4 in-article images alternating: +- Image 1 (position 0): **Square** 1024×1024 +- Image 2 (position 1): **Landscape** 1280×768 or model-specific +- Image 3 (position 2): **Square** 1024×1024 +- Image 4 (position 3): **Landscape** 1280×768 or model-specific + +**Changes:** +1. Modify `extract_image_prompts` to include size specification per image +2. Update batch generation to pass correct dimensions based on position +3. Store `aspect_ratio` or `dimensions` in Images model for template use + +--- + +## TASK 6: Content View Template - Image Layout Rules + +**Files to modify:** +- Frontend: frontend/src/pages/Writer/components/ContentViewTemplate.tsx or similar template file + +**Layout Rules:** +| Image Shape | Layout Style | +|-------------|--------------| +| **Single Square** | 50% content width, centered or left-aligned | +| **Single Landscape** | 100% content width | +| **2 Square Images** | Side by side (50% each) | +| **Square + Landscape** | Display individually per above rules | + +**Changes:** +1. Add `aspect_ratio` or `dimensions` detection from image record +2. Create layout wrapper components: + - `SingleSquareImage` (max-w-1/2) + - `SingleLandscapeImage` (w-full) + - `TwoSquareImages` (grid-cols-2) +3. Update in-article image rendering to use layout rules +4. Group consecutive square images for side-by-side display + +--- + +## TASK 7: Backend AI Model Configuration Update + +### 7A. Update Model Definitions in Database/Admin + +**Files to modify:** +- Backend: models.py (AIModelConfig) +- Backend: admin (Admin configuration) +- Backend: New migration file + +**Changes:** +1. **Add/Update AIModelConfig records for 3 models:** + + | Model | Display Name | AIR ID | Tier | + |-------|--------------|--------|------| + | Hi Dream Full | "Hi Dream Full - Basic" | `runware:97@1` | Basic | + | Bria 3.2 | "Bria 3.2 - Quality" | `bria:10@1` | Good | + | Nano Banana | "Nano Banana - Premium" | `google:4@2` | Premium | + +2. **Add new fields to AIModelConfig:** + - `parameter_preset` (CharField with dropdown): quick config presets + - `supported_sizes` (JSONField): checkboxes for valid dimensions + - `one_liner_description` (CharField): brief model explainer + +### 7B. Correct Parameter Configuration Per Model + +**Based on Runware Documentation:** + +#### **Hi Dream Full (runware:97@1)** - General Diffusion Model +```python +{ + "model": "runware:97@1", + "steps": 20, # Default, adjustable 1-100 + "CFGScale": 7, # Default + "scheduler": "Euler", # Default model scheduler + "supported_dimensions": [ + "1024x1024", # 1:1 square + "1280x768", # ~5:3 landscape (close to 16:9) + "768x1280", # ~3:5 portrait + ] +} +``` + +#### **Bria 3.2 (bria:10@1)** - Commercial-Ready +```python +{ + "model": "bria:10@1", + "steps": 8, # Bria default: 4-10 + "supported_dimensions": [ + "1024x1024", # 1:1 + "1344x768", # 16:9 landscape + "768x1344", # 9:16 portrait + "1216x832", # 3:2 landscape + "832x1216", # 2:3 portrait + ], + "providerSettings": { + "bria": { + "promptEnhancement": true, + "enhanceImage": true, + "medium": "photography", # or "art" + "contentModeration": true + } + } +} +``` + +#### **Nano Banana (google:4@2)** - Premium Quality +```python +{ + "model": "google:4@2", + "supported_dimensions": [ + # 1K tier (default) + "1024x1024", # 1:1 + "1376x768", # 16:9 landscape (1K) + "768x1376", # 9:16 portrait (1K) + "1264x848", # 3:2 landscape (1K) + # 2K tier (optional) + "2048x2048", # 1:1 2K + "2752x1536", # 16:9 2K + ], + "resolution": "1k" # or "2k", "4k" +} +``` + +### 7C. Admin Interface Enhancements + +**Files to modify:** +- Backend: backend/igny8_core/admin/billing_admin.py or similar + +**Changes:** +1. **Add model edit form with:** + - Dropdown for `parameter_preset` with explainer tooltip + - Checkboxes for `supported_sizes` (multi-select) + - TextField for `one_liner_description` + +2. **Preset Dropdown Options:** + ``` + - "Speed Optimized" (fewer steps, lighter scheduler) + - "Balanced" (default settings) + - "Quality Focused" (more steps, CFG tuning) + ``` + +3. **Size Checkboxes:** + ``` + ☑ 1024×1024 (Square) + ☑ 1280×768 (Landscape) + ☐ 768×1280 (Portrait) + ☑ 1344×768 (Wide Landscape) + ``` + +### 7D. Global Integration Settings Update + +**Files to modify:** +- Backend: global_settings_models.py + +**Changes:** +1. Update `RUNWARE_MODEL_CHOICES`: + ```python + RUNWARE_MODEL_CHOICES = [ + ('runware:97@1', 'Hi Dream Full - Basic'), + ('bria:10@1', 'Bria 3.2 - Quality'), + ('google:4@2', 'Nano Banana - Premium'), + ] + ``` + +2. Add landscape size mapping per model: + ```python + MODEL_LANDSCAPE_SIZES = { + 'runware:97@1': '1280x768', + 'bria:10@1': '1344x768', + 'google:4@2': '1376x768', + } + ``` + +3. Add `default_square_size` = "1024x1024" (universal) + +### 7E. AI Core Provider Settings + +**Files to modify:** +- Backend: ai_core.py + +**Changes:** +1. Update `_generate_image_runware` to handle provider-specific settings +2. Add Bria-specific parameters when model is `bria:*` +3. Add Google-specific handling for `google:*` models +4. Implement model-aware dimension validation + +--- + +## TASK 8: Frontend Integration Settings UI + +**Files to modify:** +- Frontend: Integration.tsx +- Frontend: Settings.tsx + +**Changes (for user override capability):** +1. **Model Selection Dropdown:** + - Hi Dream Full (Basic - Fast & Cheap) + - Bria 3.2 (Quality - Commercial Safe) + - Nano Banana (Premium - Best Quality) + +2. **Image Size Selection (if override enabled):** + - Square: 1024×1024 (all models) + - Landscape: Auto-detected based on model OR user override + +3. **Remove options:** + - Remove desktop/mobile toggle + - Remove custom size inputs + - Use fixed sizes from configuration + +--- + +## MIGRATION FILE REQUIREMENTS + +Create migration: `0024_update_image_models_and_settings.py` + +**Operations:** +1. Add `parameter_preset`, `supported_sizes`, `one_liner_description` to AIModelConfig +2. Update existing model records with new data +3. Remove deprecated fields (mobile_image_size, etc.) +4. Set default values for new fields + +--- + +## EXECUTION ORDER + +1. **Phase 1 - Backend Models & Config** (Tasks 7A, 7B, 7D, 7E) ✅ +2. **Phase 2 - Backend Logic** (Tasks 2, 5) ✅ +3. **Phase 3 - Frontend Settings** (Tasks 3, 8) ✅ +4. **Phase 4 - UI Components** (Tasks 1, 6) ✅ +5. **Phase 5 - Testing & Migration** (Run migrations in Docker) + +--- + +## VALIDATION CHECKLIST + +- [x] All 3 models configured with correct AIR IDs +- [x] Parameter presets documented and selectable +- [x] Image sizes correctly mapped per model +- [x] No duplicate in-article images (unique storage) +- [x] Modal width increased by 50% +- [x] Mobile options removed from UI +- [x] Alternating square/landscape pattern working +- [x] Content template displays images per layout rules +- [x] Global settings updated +- [ ] Admin interface has model config dropdowns (optional enhancement) + +--- + +Completed: *Create comprehensive implementation plan* (1/1) + +This plan is ready for execution. The key technical findings are: + +**Model Specifications Confirmed:** +| Model | AIR ID | Square | Landscape | Steps | +|-------|--------|--------|-----------|-------| +| Hi Dream Full | `runware:97@1` | 1024×1024 | 1280×768 | 20 | +| Bria 3.2 | `bria:10@1` | 1024×1024 | 1344×768 | 8 | +| Nano Banana | `google:4@2` | 1024×1024 | 1376×768 | Auto | diff --git a/frontend/src/components/common/ImageQueueModal.tsx b/frontend/src/components/common/ImageQueueModal.tsx index 299af8fc..f18af1d8 100644 --- a/frontend/src/components/common/ImageQueueModal.tsx +++ b/frontend/src/components/common/ImageQueueModal.tsx @@ -466,7 +466,7 @@ export default function ImageQueueModal({ {/* Header */} diff --git a/frontend/src/pages/Settings/Integration.tsx b/frontend/src/pages/Settings/Integration.tsx index dc904769..e333b9cb 100644 --- a/frontend/src/pages/Settings/Integration.tsx +++ b/frontend/src/pages/Settings/Integration.tsx @@ -60,12 +60,9 @@ interface IntegrationConfig { runwareModel?: string; // Runware model: 'runware:97@1', etc. // Image generation settings image_type?: string; // 'realistic', 'artistic', 'cartoon' - max_in_article_images?: number; // 1-5 + max_in_article_images?: number; // 1-4 image_format?: string; // 'webp', 'jpg', 'png' - desktop_enabled?: boolean; - mobile_enabled?: boolean; - featured_image_size?: string; // e.g., '1280x832', '1024x1024' - desktop_image_size?: string; // e.g., '1024x1024', '512x512' + featured_image_size?: string; // e.g., '1280x768', '1024x1024' - auto-determined by model } export default function Integration() { @@ -90,12 +87,9 @@ export default function Integration() { model: 'dall-e-3', // OpenAI model if service is 'openai' runwareModel: 'runware:97@1', // Runware model if service is 'runware' image_type: 'realistic', // 'realistic', 'artistic', 'cartoon' - max_in_article_images: 2, // 1-5 + max_in_article_images: 2, // 1-4 image_format: 'webp', // 'webp', 'jpg', 'png' - desktop_enabled: true, - mobile_enabled: true, - featured_image_size: '1024x1024', // Default, will be set based on provider/model - desktop_image_size: '1024x1024', // Default, will be set based on provider/model + featured_image_size: '1280x768', // Default, auto-determined by model }, }); @@ -373,10 +367,7 @@ export default function Integration() { image_type: config.image_type || 'realistic', max_in_article_images: config.max_in_article_images || 2, image_format: config.image_format || 'webp', - desktop_enabled: config.desktop_enabled !== undefined ? config.desktop_enabled : true, - mobile_enabled: config.mobile_enabled !== undefined ? config.mobile_enabled : true, featured_image_size: config.featured_image_size || defaultFeaturedSize, - desktop_image_size: config.desktop_image_size || defaultDesktopSize, }; } @@ -435,12 +426,19 @@ export default function Integration() { }; // Get available image sizes with prices based on provider and model + // Note: Sizes are now auto-determined - square (1024x1024) and landscape (model-specific) const getImageSizes = useCallback((provider: string, model: string) => { if (provider === 'runware') { + // Model-specific landscape sizes, square is always 1024x1024 + const MODEL_LANDSCAPE_SIZES: Record = { + 'runware:97@1': { value: '1280x768', label: '1280×768 pixels' }, // Hi Dream Full + 'bria:10@1': { value: '1344x768', label: '1344×768 pixels' }, // Bria 3.2 + 'google:4@2': { value: '1376x768', label: '1376×768 pixels' }, // Nano Banana + }; + const landscapeSize = MODEL_LANDSCAPE_SIZES[model] || { value: '1280x768', label: '1280×768 pixels' }; return [ - { value: '1280x832', label: '1280×832 pixels - $0.009', price: 0.009 }, - { value: '1024x1024', label: '1024×1024 pixels - $0.009', price: 0.009 }, - { value: '512x512', label: '512×512 pixels - $0.006', price: 0.006 }, + { value: landscapeSize.value, label: `${landscapeSize.label} - Landscape`, price: 0.009 }, + { value: '1024x1024', label: '1024×1024 pixels - Square', price: 0.009 }, ]; } else if (provider === 'openai') { if (model === 'dall-e-2') { @@ -552,8 +550,9 @@ export default function Integration() { }); }, options: [ - { value: 'runware:97@1', label: 'Hi Dream Full - Standard' }, - { value: 'civitai:618692@691639', label: 'Bria 3.2 - Premium' }, + { value: 'runware:97@1', label: 'Hi Dream Full - Basic' }, + { value: 'bria:10@1', label: 'Bria 3.2 - Quality' }, + { value: 'google:4@2', label: 'Nano Banana - Premium' }, ], }); } @@ -633,23 +632,19 @@ export default function Integration() { const availableSizes = getImageSizes(service, model); if (availableSizes.length > 0) { - const defaultSize = availableSizes[0].value; + const defaultSize = availableSizes[0].value; // First option is landscape (featured image default) const currentFeaturedSize = config.featured_image_size; - const currentDesktopSize = config.desktop_image_size; - // Check if current sizes are valid for the new provider/model + // Check if current featured size is valid for the new provider/model const validSizes = availableSizes.map(s => s.value); - const needsUpdate = - !currentFeaturedSize || !validSizes.includes(currentFeaturedSize) || - !currentDesktopSize || !validSizes.includes(currentDesktopSize); + const needsUpdate = !currentFeaturedSize || !validSizes.includes(currentFeaturedSize); if (needsUpdate) { setIntegrations({ ...integrations, [selectedIntegration]: { ...config, - featured_image_size: validSizes.includes(currentFeaturedSize || '') ? currentFeaturedSize : defaultSize, - desktop_image_size: validSizes.includes(currentDesktopSize || '') ? currentDesktopSize : defaultSize, + featured_image_size: defaultSize, }, }); } @@ -743,67 +738,15 @@ export default function Integration() { - {/* Row 2: Desktop & Mobile Images (2 columns) */} -
- {/* Desktop Images Checkbox with Size Selector */} -
-
- { - setIntegrations({ - ...integrations, - [selectedIntegration]: { - ...integrations[selectedIntegration], - desktop_enabled: checked, - }, - }); - }} - /> - -
- {integrations[selectedIntegration]?.desktop_enabled !== false && ( - { - setIntegrations({ - ...integrations, - [selectedIntegration]: { - ...integrations[selectedIntegration], - desktop_image_size: value, - }, - }); - }} - className="w-full" - /> - )} -
- - {/* Mobile Images Checkbox - Fixed to 512x512 */} -
- { - setIntegrations({ - ...integrations, - [selectedIntegration]: { - ...integrations[selectedIntegration], - mobile_enabled: checked, - }, - }); - }} - /> -
- -
- 512×512 pixels -
-
+ {/* Row 2: Image Size Info */} +
+
+ Image Sizes (auto-determined): +
    +
  • Featured image: Landscape ({getImageSizes(service, service === 'openai' ? (integrations[selectedIntegration]?.model || 'dall-e-3') : (integrations[selectedIntegration]?.runwareModel || 'runware:97@1'))[0]?.label.split(' - ')[0] || 'model-specific'})
  • +
  • In-article images: Alternating pattern (Square → Landscape → Square → Landscape)
  • +
  • Square: 1024×1024 pixels (universal)
  • +
@@ -921,8 +864,9 @@ export default function Integration() { ? (() => { // Map model ID to display name const modelDisplayNames: Record = { - 'runware:97@1': 'Hi Dream Full - Standard', - 'civitai:618692@691639': 'Bria 3.2 - Premium', + 'runware:97@1': 'Hi Dream Full - Basic', + 'bria:10@1': 'Bria 3.2 - Quality', + 'google:4@2': 'Nano Banana - Premium', }; return modelDisplayNames[integrations.image_generation.runwareModel] || integrations.image_generation.runwareModel; })() diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index 28471f88..4603449f 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -86,21 +86,26 @@ export default function SiteSettings() { image_type: 'realistic' as 'realistic' | 'artistic' | 'cartoon', max_in_article_images: 2, image_format: 'webp' as 'webp' | 'jpg' | 'png', - desktop_enabled: true, - mobile_enabled: true, featured_image_size: '1024x1024', - desktop_image_size: '1024x1024', }); const [imageSettingsLoading, setImageSettingsLoading] = useState(false); const [imageSettingsSaving, setImageSettingsSaving] = useState(false); // Image quality to config mapping + // Updated to use new Runware models via API const QUALITY_TO_CONFIG: Record = { standard: { service: 'openai', model: 'dall-e-2' }, premium: { service: 'openai', model: 'dall-e-3' }, - best: { service: 'runware', model: 'runware:97@1' }, + best: { service: 'runware', model: 'runware:97@1' }, // Uses model-specific landscape size }; + // Runware model choices with descriptions + const RUNWARE_MODEL_CHOICES = [ + { value: 'runware:97@1', label: 'Hi Dream Full - Basic', description: 'Fast & affordable' }, + { value: 'bria:10@1', label: 'Bria 3.2 - Quality', description: 'Commercial-safe, licensed data' }, + { value: 'google:4@2', label: 'Nano Banana - Premium', description: 'Best quality, text rendering' }, + ]; + const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'premium' | 'best' => { if (service === 'runware') return 'best'; if (model === 'dall-e-3') return 'premium'; @@ -109,10 +114,11 @@ export default function SiteSettings() { const getImageSizes = (provider: string, model: string) => { if (provider === 'runware') { + // Model-specific sizes - featured uses landscape, in-article alternates + // Sizes shown are for featured image (landscape) return [ - { value: '1280x832', label: '1280×832 pixels' }, - { value: '1024x1024', label: '1024×1024 pixels' }, - { value: '512x512', label: '512×512 pixels' }, + { value: '1280x768', label: '1280×768 (Landscape)' }, + { value: '1024x1024', label: '1024×1024 (Square)' }, ]; } else if (provider === 'openai') { if (model === 'dall-e-2') { @@ -230,16 +236,14 @@ export default function SiteSettings() { const validSizes = sizes.map(s => s.value); const needsFeaturedUpdate = !validSizes.includes(imageSettings.featured_image_size); - const needsDesktopUpdate = !validSizes.includes(imageSettings.desktop_image_size); - if (needsFeaturedUpdate || needsDesktopUpdate) { + if (needsFeaturedUpdate) { setImageSettings(prev => ({ ...prev, service: config.service, provider: config.service, model: config.model, featured_image_size: needsFeaturedUpdate ? defaultSize : prev.featured_image_size, - desktop_image_size: needsDesktopUpdate ? defaultSize : prev.desktop_image_size, })); } else { setImageSettings(prev => ({ @@ -438,10 +442,7 @@ export default function SiteSettings() { image_type: imageData.image_type || 'realistic', max_in_article_images: imageData.max_in_article_images || 2, image_format: imageData.image_format || 'webp', - desktop_enabled: imageData.desktop_enabled !== false, - mobile_enabled: imageData.mobile_enabled !== false, featured_image_size: imageData.featured_image_size || '1024x1024', - desktop_image_size: imageData.desktop_image_size || '1024x1024', }); } } catch (error: any) { @@ -464,10 +465,7 @@ export default function SiteSettings() { image_type: imageSettings.image_type, max_in_article_images: imageSettings.max_in_article_images, image_format: imageSettings.image_format, - desktop_enabled: imageSettings.desktop_enabled, - mobile_enabled: imageSettings.mobile_enabled, featured_image_size: imageSettings.featured_image_size, - desktop_image_size: imageSettings.desktop_image_size, }; await fetchAPI('/v1/system/settings/integrations/image_generation/save/', { @@ -1023,7 +1021,7 @@ export default function SiteSettings() {
Featured Image Size
-
Always Enabled
+
Landscape (Model-specific)
setImageSettings({ ...imageSettings, featured_image_size: value })} className="w-full [&_.igny8-select-styled]:bg-white/10 [&_.igny8-select-styled]:border-white/20 [&_.igny8-select-styled]:text-white" /> +

+ In-article images alternate: Square (1024×1024) → Landscape → Square → Landscape +

- {/* Row 3: Desktop & Mobile Images */} -
-
-
- setImageSettings({ ...imageSettings, desktop_enabled: checked })} - /> - -
- {imageSettings.desktop_enabled && ( - setImageSettings({ ...imageSettings, desktop_image_size: value })} - className="w-full" - /> - )} -
- -
- setImageSettings({ ...imageSettings, mobile_enabled: checked })} - /> -
- -
512×512 pixels
-
-
-
- - {/* Row 4: Max Images & Format */} + {/* Row 3: Max Images & Format */}
@@ -1076,12 +1045,14 @@ export default function SiteSettings() { { value: '2', label: '2 Images' }, { value: '3', label: '3 Images' }, { value: '4', label: '4 Images' }, - { value: '5', label: '5 Images' }, ]} value={String(imageSettings.max_in_article_images)} onChange={(value) => setImageSettings({ ...imageSettings, max_in_article_images: parseInt(value) })} className="w-full" /> +

+ Images 1 & 3: Square | Images 2 & 4: Landscape +

diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e965c01c..f0d1a5b7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1409,6 +1409,7 @@ export interface ImageRecord { caption?: string | null; status: string; position: number; + aspect_ratio?: 'square' | 'landscape'; // square for position 0,2 | landscape for position 1,3 created_at: string; updated_at: string; account_id?: number | null; diff --git a/frontend/src/templates/ContentViewTemplate.tsx b/frontend/src/templates/ContentViewTemplate.tsx index 5ba3488a..376a952f 100644 --- a/frontend/src/templates/ContentViewTemplate.tsx +++ b/frontend/src/templates/ContentViewTemplate.tsx @@ -390,37 +390,53 @@ const splitAtFirstH3 = (html: string): { beforeH3: string; h3AndAfter: string } }; }; -// Helper to check if section contains a table -const hasTable = (html: string): boolean => { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - return doc.querySelector('table') !== null; -}; - +/** + * ContentSectionBlock - Renders a content section with image layout based on aspect ratio + * + * Layout rules: + * - Single landscape image: 100% width (full width) + * - Single square image: 50% width (centered) + * - Two square images (paired): Side by side (50% each) + */ const ContentSectionBlock = ({ section, image, loading, index, - imagePlacement = 'right', - firstImage = null, + aspectRatio = 'square', + pairedSquareImage = null, }: { section: ArticleSection; image: ImageRecord | null; loading: boolean; index: number; - imagePlacement?: 'left' | 'center' | 'right'; - firstImage?: ImageRecord | null; + aspectRatio?: 'square' | 'landscape'; + pairedSquareImage?: ImageRecord | null; }) => { const hasImage = Boolean(image); + const hasPairedImage = Boolean(pairedSquareImage); const headingLabel = section.heading || `Section ${index + 1}`; - const sectionHasTable = hasTable(section.bodyHtml); const { beforeH3, h3AndAfter } = splitAtFirstH3(section.bodyHtml); + // Determine image container width class based on aspect ratio and pairing + const getImageContainerClass = () => { + if (hasPairedImage) { + // Two squares side by side + return 'w-full'; + } + if (aspectRatio === 'landscape') { + // Landscape: 100% width + return 'w-full'; + } + // Single square: 50% width centered + return 'w-full max-w-[50%]'; + }; + return (
+ {/* Section header */}
{index + 1} @@ -435,86 +451,51 @@ const ContentSectionBlock = ({
- {imagePlacement === 'center' && hasImage ? ( -
- {/* Content before H3 */} - {beforeH3 && ( -
-
-
- )} - - {/* Centered image before H3 */} + {/* Content layout with images */} +
+ {/* Content before H3 */} + {beforeH3 && ( +
+
+
+ )} + + {/* Image section - layout depends on aspect ratio */} + {hasImage && (
-
- -
+ {hasPairedImage ? ( + // Two squares side by side (50% each) +
+
+ +
+
+ +
+
+ ) : ( + // Single image with width based on aspect ratio +
+ +
+ )}
- - {/* H3 and remaining content */} - {h3AndAfter && ( -
-
-
- )} - - {/* Fallback if no H3 found */} - {!beforeH3 && !h3AndAfter && ( -
-
-
- )} -
- ) : sectionHasTable && hasImage && firstImage ? ( -
- {/* Content before H3 */} - {beforeH3 && ( -
-
-
- )} - - {/* Two images side by side at 50% width each */} -
-
- -
-
- -
+ )} + + {/* H3 and remaining content */} + {h3AndAfter && ( +
+
- - {/* H3 and remaining content */} - {h3AndAfter && ( -
-
-
- )} - - {/* Fallback if no H3 found */} - {!beforeH3 && !h3AndAfter && ( -
-
-
- )} -
- ) : ( -
- {imagePlacement === 'left' && hasImage && ( -
- -
- )} -
+ )} + + {/* Fallback if no H3 structure found */} + {!beforeH3 && !h3AndAfter && ( +
- {imagePlacement === 'right' && hasImage && ( -
- -
- )} -
- )} + )} +
@@ -532,12 +513,21 @@ interface ArticleBodyProps { const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => { const hasStructuredSections = sections.length > 0; - // Calculate image placement: right → center → left → repeat - const getImagePlacement = (index: number): 'left' | 'center' | 'right' => { - const position = index % 3; - if (position === 0) return 'right'; - if (position === 1) return 'center'; - return 'left'; + // Determine image aspect ratio from record or fallback to position-based calculation + // Position 0, 2 = square (1024x1024), Position 1, 3 = landscape (model-specific) + const getImageAspectRatio = (image: ImageRecord | null, index: number): 'square' | 'landscape' => { + if (image?.aspect_ratio) return image.aspect_ratio; + // Fallback: even positions (0, 2) are square, odd positions (1, 3) are landscape + return index % 2 === 0 ? 'square' : 'landscape'; + }; + + // Check if two consecutive images are both squares (for side-by-side layout) + const getNextSquareImage = (currentIndex: number): ImageRecord | null => { + const nextImage = sectionImages[currentIndex + 1]; + if (nextImage && getImageAspectRatio(nextImage, currentIndex + 1) === 'square') { + return nextImage; + } + return null; }; if (!hasStructuredSections && !introHtml && rawHtml) { @@ -553,20 +543,42 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm // Get the first in-article image (position 0) const firstImage = sectionImages.length > 0 ? sectionImages[0] : null; + // Track which images have been rendered as pairs (to skip the second in the pair) + const renderedPairIndices = new Set(); + return (
{introHtml && } - {sections.map((section, index) => ( - - ))} + {sections.map((section, index) => { + // Skip if this image was already rendered as part of a pair + if (renderedPairIndices.has(index)) { + return null; + } + + const currentImage = sectionImages[index] ?? null; + const currentAspectRatio = getImageAspectRatio(currentImage, index); + + // Check if current is square and next is also square for side-by-side layout + let pairedSquareImage: ImageRecord | null = null; + if (currentAspectRatio === 'square') { + pairedSquareImage = getNextSquareImage(index); + if (pairedSquareImage) { + renderedPairIndices.add(index + 1); // Mark next as rendered + } + } + + return ( + + ); + })}
); };