IMage genartion service and models revamp - #Migration Runs
This commit is contained in:
@@ -982,24 +982,51 @@ class AICore:
|
|||||||
# Runware uses array payload with authentication task first, then imageInference
|
# Runware uses array payload with authentication task first, then imageInference
|
||||||
# Reference: image-generation.php lines 79-97
|
# Reference: image-generation.php lines 79-97
|
||||||
import uuid
|
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 = [
|
payload = [
|
||||||
{
|
{
|
||||||
'taskType': 'authentication',
|
'taskType': 'authentication',
|
||||||
'apiKey': api_key
|
'apiKey': api_key
|
||||||
},
|
},
|
||||||
{
|
inference_task
|
||||||
'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'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
request_start = time.time()
|
request_start = time.time()
|
||||||
|
|||||||
@@ -101,8 +101,6 @@ class GenerateImagesFunction(BaseAIFunction):
|
|||||||
'model': model,
|
'model': model,
|
||||||
'image_type': image_settings.get('image_type') or global_settings.image_style,
|
'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),
|
'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:
|
def build_prompt(self, data: Dict, account=None) -> Dict:
|
||||||
|
|||||||
@@ -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")
|
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_type = config.get('image_type') or global_settings.image_style
|
||||||
image_format = config.get('image_format', 'webp')
|
image_format = config.get('image_format', 'webp')
|
||||||
desktop_enabled = config.get('desktop_enabled', True)
|
|
||||||
mobile_enabled = config.get('mobile_enabled', True)
|
# Model-specific landscape sizes (square is always 1024x1024)
|
||||||
# Get image sizes from config, with fallback defaults
|
# Based on Runware documentation for optimal results per model
|
||||||
featured_image_size = config.get('featured_image_size') or ('1280x832' if provider == 'runware' else '1024x1024')
|
MODEL_LANDSCAPE_SIZES = {
|
||||||
desktop_image_size = config.get('desktop_image_size') or global_settings.desktop_image_size
|
'runware:97@1': '1280x768', # Hi Dream Full landscape
|
||||||
in_article_image_size = config.get('in_article_image_size') or '512x512' # Default to 512x512
|
'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"[process_image_generation_queue] Settings loaded:")
|
||||||
logger.info(f" - Provider: {provider}")
|
logger.info(f" - Provider: {provider}")
|
||||||
logger.info(f" - Model: {model}")
|
logger.info(f" - Model: {model}")
|
||||||
logger.info(f" - Image type: {image_type}")
|
logger.info(f" - Image type: {image_type}")
|
||||||
logger.info(f" - Image format: {image_format}")
|
logger.info(f" - Image format: {image_format}")
|
||||||
logger.info(f" - Desktop enabled: {desktop_enabled}")
|
logger.info(f" - Featured image size: {featured_image_size}")
|
||||||
logger.info(f" - Mobile enabled: {mobile_enabled}")
|
logger.info(f" - In-article square: {in_article_square_size}, landscape: {in_article_landscape_size}")
|
||||||
|
|
||||||
# Get provider API key
|
# Get provider API key
|
||||||
# API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys)
|
# 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':
|
if image.image_type == 'featured':
|
||||||
image_size = featured_image_size # Read from config
|
image_size = featured_image_size # Model-specific landscape
|
||||||
elif image.image_type == 'desktop':
|
elif image.image_type == 'in_article':
|
||||||
image_size = desktop_image_size
|
# Alternate based on position: even=square, odd=landscape
|
||||||
elif image.image_type == 'mobile':
|
position = image.position or 0
|
||||||
image_size = '512x512' # Fixed mobile size
|
if position % 2 == 0: # Position 0, 2: Square
|
||||||
else: # in_article or other
|
image_size = in_article_square_size
|
||||||
image_size = in_article_image_size # Read from config, default 512x512
|
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(
|
result = ai_core.generate_image(
|
||||||
prompt=formatted_prompt,
|
prompt=formatted_prompt,
|
||||||
|
|||||||
@@ -568,10 +568,33 @@ class Images(SoftDeletableModel, SiteSectorBaseModel):
|
|||||||
models.Index(fields=['content', 'position']),
|
models.Index(fields=['content', 'position']),
|
||||||
models.Index(fields=['task', '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()
|
objects = SoftDeleteManager()
|
||||||
all_objects = models.Manager()
|
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):
|
def save(self, *args, **kwargs):
|
||||||
"""Track image usage when creating new images"""
|
"""Track image usage when creating new images"""
|
||||||
is_new = self.pk is None
|
is_new = self.pk is None
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -63,8 +63,9 @@ def get_image_model_choices(provider=None):
|
|||||||
]
|
]
|
||||||
elif provider == 'runware':
|
elif provider == 'runware':
|
||||||
return [
|
return [
|
||||||
('runware:97@1', 'Hi Dream Full - Standard'),
|
('runware:97@1', 'Hi Dream Full - Basic'),
|
||||||
('civitai:618692@691639', 'Bria 3.2 - Premium'),
|
('bria:10@1', 'Bria 3.2 - Quality'),
|
||||||
|
('google:4@2', 'Nano Banana - Premium'),
|
||||||
]
|
]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -171,10 +172,21 @@ class GlobalIntegrationSettings(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
RUNWARE_MODEL_CHOICES = [
|
RUNWARE_MODEL_CHOICES = [
|
||||||
('runware:97@1', 'Hi Dream Full - Standard'),
|
('runware:97@1', 'Hi Dream Full - Basic'),
|
||||||
('civitai:618692@691639', 'Bria 3.2 - Premium'),
|
('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_MODEL_CHOICES = [
|
||||||
('bria-2.3', 'Bria 2.3 - High Quality ($0.015/image)'),
|
('bria-2.3', 'Bria 2.3 - High Quality ($0.015/image)'),
|
||||||
('bria-2.3-fast', 'Bria 2.3 Fast - Quick Generation ($0.010/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(
|
desktop_image_size = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
default='1024x1024',
|
default='1024x1024',
|
||||||
help_text="Default desktop image size (accounts can override if plan allows)"
|
help_text="Default image size for in-article images (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)"
|
|
||||||
)
|
)
|
||||||
|
# Note: mobile_image_size removed - no longer needed
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -76,6 +76,7 @@ class ImagesSerializer(serializers.ModelSerializer):
|
|||||||
"""Serializer for Images model"""
|
"""Serializer for Images model"""
|
||||||
task_title = serializers.SerializerMethodField()
|
task_title = serializers.SerializerMethodField()
|
||||||
content_title = serializers.SerializerMethodField()
|
content_title = serializers.SerializerMethodField()
|
||||||
|
aspect_ratio = serializers.ReadOnlyField() # Expose aspect_ratio property
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Images
|
model = Images
|
||||||
@@ -92,11 +93,12 @@ class ImagesSerializer(serializers.ModelSerializer):
|
|||||||
'caption',
|
'caption',
|
||||||
'status',
|
'status',
|
||||||
'position',
|
'position',
|
||||||
|
'aspect_ratio',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
'account_id',
|
'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):
|
def get_task_title(self, obj):
|
||||||
"""Get task title"""
|
"""Get task title"""
|
||||||
|
|||||||
343
docs/plans/IMAGE_MODELS_IMPLEMENTATION_PLAN.md
Normal file
343
docs/plans/IMAGE_MODELS_IMPLEMENTATION_PLAN.md
Normal file
@@ -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 |
|
||||||
@@ -466,7 +466,7 @@ export default function ImageQueueModal({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
className="max-w-6xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
showCloseButton={!isProcessing}
|
showCloseButton={!isProcessing}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -60,12 +60,9 @@ interface IntegrationConfig {
|
|||||||
runwareModel?: string; // Runware model: 'runware:97@1', etc.
|
runwareModel?: string; // Runware model: 'runware:97@1', etc.
|
||||||
// Image generation settings
|
// Image generation settings
|
||||||
image_type?: string; // 'realistic', 'artistic', 'cartoon'
|
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'
|
image_format?: string; // 'webp', 'jpg', 'png'
|
||||||
desktop_enabled?: boolean;
|
featured_image_size?: string; // e.g., '1280x768', '1024x1024' - auto-determined by model
|
||||||
mobile_enabled?: boolean;
|
|
||||||
featured_image_size?: string; // e.g., '1280x832', '1024x1024'
|
|
||||||
desktop_image_size?: string; // e.g., '1024x1024', '512x512'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Integration() {
|
export default function Integration() {
|
||||||
@@ -90,12 +87,9 @@ export default function Integration() {
|
|||||||
model: 'dall-e-3', // OpenAI model if service is 'openai'
|
model: 'dall-e-3', // OpenAI model if service is 'openai'
|
||||||
runwareModel: 'runware:97@1', // Runware model if service is 'runware'
|
runwareModel: 'runware:97@1', // Runware model if service is 'runware'
|
||||||
image_type: 'realistic', // 'realistic', 'artistic', 'cartoon'
|
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'
|
image_format: 'webp', // 'webp', 'jpg', 'png'
|
||||||
desktop_enabled: true,
|
featured_image_size: '1280x768', // Default, auto-determined by model
|
||||||
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
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -373,10 +367,7 @@ export default function Integration() {
|
|||||||
image_type: config.image_type || 'realistic',
|
image_type: config.image_type || 'realistic',
|
||||||
max_in_article_images: config.max_in_article_images || 2,
|
max_in_article_images: config.max_in_article_images || 2,
|
||||||
image_format: config.image_format || 'webp',
|
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,
|
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
|
// 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) => {
|
const getImageSizes = useCallback((provider: string, model: string) => {
|
||||||
if (provider === 'runware') {
|
if (provider === 'runware') {
|
||||||
|
// Model-specific landscape sizes, square is always 1024x1024
|
||||||
|
const MODEL_LANDSCAPE_SIZES: Record<string, { value: string; label: string }> = {
|
||||||
|
'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 [
|
return [
|
||||||
{ value: '1280x832', label: '1280×832 pixels - $0.009', price: 0.009 },
|
{ value: landscapeSize.value, label: `${landscapeSize.label} - Landscape`, price: 0.009 },
|
||||||
{ value: '1024x1024', label: '1024×1024 pixels - $0.009', price: 0.009 },
|
{ value: '1024x1024', label: '1024×1024 pixels - Square', price: 0.009 },
|
||||||
{ value: '512x512', label: '512×512 pixels - $0.006', price: 0.006 },
|
|
||||||
];
|
];
|
||||||
} else if (provider === 'openai') {
|
} else if (provider === 'openai') {
|
||||||
if (model === 'dall-e-2') {
|
if (model === 'dall-e-2') {
|
||||||
@@ -552,8 +550,9 @@ export default function Integration() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
{ value: 'runware:97@1', label: 'Hi Dream Full - Standard' },
|
{ value: 'runware:97@1', label: 'Hi Dream Full - Basic' },
|
||||||
{ value: 'civitai:618692@691639', label: 'Bria 3.2 - Premium' },
|
{ 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);
|
const availableSizes = getImageSizes(service, model);
|
||||||
|
|
||||||
if (availableSizes.length > 0) {
|
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 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 validSizes = availableSizes.map(s => s.value);
|
||||||
const needsUpdate =
|
const needsUpdate = !currentFeaturedSize || !validSizes.includes(currentFeaturedSize);
|
||||||
!currentFeaturedSize || !validSizes.includes(currentFeaturedSize) ||
|
|
||||||
!currentDesktopSize || !validSizes.includes(currentDesktopSize);
|
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
setIntegrations({
|
setIntegrations({
|
||||||
...integrations,
|
...integrations,
|
||||||
[selectedIntegration]: {
|
[selectedIntegration]: {
|
||||||
...config,
|
...config,
|
||||||
featured_image_size: validSizes.includes(currentFeaturedSize || '') ? currentFeaturedSize : defaultSize,
|
featured_image_size: defaultSize,
|
||||||
desktop_image_size: validSizes.includes(currentDesktopSize || '') ? currentDesktopSize : defaultSize,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -743,67 +738,15 @@ export default function Integration() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Desktop & Mobile Images (2 columns) */}
|
{/* Row 2: Image Size Info */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
{/* Desktop Images Checkbox with Size Selector */}
|
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
<div className="p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2">
|
<strong>Image Sizes (auto-determined):</strong>
|
||||||
<div className="flex items-center gap-3">
|
<ul className="mt-1 list-disc list-inside text-xs space-y-1">
|
||||||
<Checkbox
|
<li>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'})</li>
|
||||||
checked={integrations[selectedIntegration]?.desktop_enabled !== false}
|
<li>In-article images: Alternating pattern (Square → Landscape → Square → Landscape)</li>
|
||||||
onChange={(checked) => {
|
<li>Square: 1024×1024 pixels (universal)</li>
|
||||||
setIntegrations({
|
</ul>
|
||||||
...integrations,
|
|
||||||
[selectedIntegration]: {
|
|
||||||
...integrations[selectedIntegration],
|
|
||||||
desktop_enabled: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label className="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Desktop Images
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{integrations[selectedIntegration]?.desktop_enabled !== false && (
|
|
||||||
<SelectDropdown
|
|
||||||
options={getImageSizes(service, service === 'openai' ? (integrations[selectedIntegration]?.model || 'dall-e-3') : (integrations[selectedIntegration]?.runwareModel || 'runware:97@1'))}
|
|
||||||
value={integrations[selectedIntegration]?.desktop_image_size || '1024x1024'}
|
|
||||||
onChange={(value) => {
|
|
||||||
setIntegrations({
|
|
||||||
...integrations,
|
|
||||||
[selectedIntegration]: {
|
|
||||||
...integrations[selectedIntegration],
|
|
||||||
desktop_image_size: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Images Checkbox - Fixed to 512x512 */}
|
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
||||||
<Checkbox
|
|
||||||
checked={integrations[selectedIntegration]?.mobile_enabled !== false}
|
|
||||||
onChange={(checked) => {
|
|
||||||
setIntegrations({
|
|
||||||
...integrations,
|
|
||||||
[selectedIntegration]: {
|
|
||||||
...integrations[selectedIntegration],
|
|
||||||
mobile_enabled: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Label className="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Mobile Images
|
|
||||||
</Label>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
512×512 pixels
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -921,8 +864,9 @@ export default function Integration() {
|
|||||||
? (() => {
|
? (() => {
|
||||||
// Map model ID to display name
|
// Map model ID to display name
|
||||||
const modelDisplayNames: Record<string, string> = {
|
const modelDisplayNames: Record<string, string> = {
|
||||||
'runware:97@1': 'Hi Dream Full - Standard',
|
'runware:97@1': 'Hi Dream Full - Basic',
|
||||||
'civitai:618692@691639': 'Bria 3.2 - Premium',
|
'bria:10@1': 'Bria 3.2 - Quality',
|
||||||
|
'google:4@2': 'Nano Banana - Premium',
|
||||||
};
|
};
|
||||||
return modelDisplayNames[integrations.image_generation.runwareModel] || integrations.image_generation.runwareModel;
|
return modelDisplayNames[integrations.image_generation.runwareModel] || integrations.image_generation.runwareModel;
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -86,21 +86,26 @@ export default function SiteSettings() {
|
|||||||
image_type: 'realistic' as 'realistic' | 'artistic' | 'cartoon',
|
image_type: 'realistic' as 'realistic' | 'artistic' | 'cartoon',
|
||||||
max_in_article_images: 2,
|
max_in_article_images: 2,
|
||||||
image_format: 'webp' as 'webp' | 'jpg' | 'png',
|
image_format: 'webp' as 'webp' | 'jpg' | 'png',
|
||||||
desktop_enabled: true,
|
|
||||||
mobile_enabled: true,
|
|
||||||
featured_image_size: '1024x1024',
|
featured_image_size: '1024x1024',
|
||||||
desktop_image_size: '1024x1024',
|
|
||||||
});
|
});
|
||||||
const [imageSettingsLoading, setImageSettingsLoading] = useState(false);
|
const [imageSettingsLoading, setImageSettingsLoading] = useState(false);
|
||||||
const [imageSettingsSaving, setImageSettingsSaving] = useState(false);
|
const [imageSettingsSaving, setImageSettingsSaving] = useState(false);
|
||||||
|
|
||||||
// Image quality to config mapping
|
// Image quality to config mapping
|
||||||
|
// Updated to use new Runware models via API
|
||||||
const QUALITY_TO_CONFIG: Record<string, { service: 'openai' | 'runware'; model: string }> = {
|
const QUALITY_TO_CONFIG: Record<string, { service: 'openai' | 'runware'; model: string }> = {
|
||||||
standard: { service: 'openai', model: 'dall-e-2' },
|
standard: { service: 'openai', model: 'dall-e-2' },
|
||||||
premium: { service: 'openai', model: 'dall-e-3' },
|
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' => {
|
const getQualityFromConfig = (service?: string, model?: string): 'standard' | 'premium' | 'best' => {
|
||||||
if (service === 'runware') return 'best';
|
if (service === 'runware') return 'best';
|
||||||
if (model === 'dall-e-3') return 'premium';
|
if (model === 'dall-e-3') return 'premium';
|
||||||
@@ -109,10 +114,11 @@ export default function SiteSettings() {
|
|||||||
|
|
||||||
const getImageSizes = (provider: string, model: string) => {
|
const getImageSizes = (provider: string, model: string) => {
|
||||||
if (provider === 'runware') {
|
if (provider === 'runware') {
|
||||||
|
// Model-specific sizes - featured uses landscape, in-article alternates
|
||||||
|
// Sizes shown are for featured image (landscape)
|
||||||
return [
|
return [
|
||||||
{ value: '1280x832', label: '1280×832 pixels' },
|
{ value: '1280x768', label: '1280×768 (Landscape)' },
|
||||||
{ value: '1024x1024', label: '1024×1024 pixels' },
|
{ value: '1024x1024', label: '1024×1024 (Square)' },
|
||||||
{ value: '512x512', label: '512×512 pixels' },
|
|
||||||
];
|
];
|
||||||
} else if (provider === 'openai') {
|
} else if (provider === 'openai') {
|
||||||
if (model === 'dall-e-2') {
|
if (model === 'dall-e-2') {
|
||||||
@@ -230,16 +236,14 @@ export default function SiteSettings() {
|
|||||||
|
|
||||||
const validSizes = sizes.map(s => s.value);
|
const validSizes = sizes.map(s => s.value);
|
||||||
const needsFeaturedUpdate = !validSizes.includes(imageSettings.featured_image_size);
|
const needsFeaturedUpdate = !validSizes.includes(imageSettings.featured_image_size);
|
||||||
const needsDesktopUpdate = !validSizes.includes(imageSettings.desktop_image_size);
|
|
||||||
|
|
||||||
if (needsFeaturedUpdate || needsDesktopUpdate) {
|
if (needsFeaturedUpdate) {
|
||||||
setImageSettings(prev => ({
|
setImageSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
service: config.service,
|
service: config.service,
|
||||||
provider: config.service,
|
provider: config.service,
|
||||||
model: config.model,
|
model: config.model,
|
||||||
featured_image_size: needsFeaturedUpdate ? defaultSize : prev.featured_image_size,
|
featured_image_size: needsFeaturedUpdate ? defaultSize : prev.featured_image_size,
|
||||||
desktop_image_size: needsDesktopUpdate ? defaultSize : prev.desktop_image_size,
|
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
setImageSettings(prev => ({
|
setImageSettings(prev => ({
|
||||||
@@ -438,10 +442,7 @@ export default function SiteSettings() {
|
|||||||
image_type: imageData.image_type || 'realistic',
|
image_type: imageData.image_type || 'realistic',
|
||||||
max_in_article_images: imageData.max_in_article_images || 2,
|
max_in_article_images: imageData.max_in_article_images || 2,
|
||||||
image_format: imageData.image_format || 'webp',
|
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',
|
featured_image_size: imageData.featured_image_size || '1024x1024',
|
||||||
desktop_image_size: imageData.desktop_image_size || '1024x1024',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -464,10 +465,7 @@ export default function SiteSettings() {
|
|||||||
image_type: imageSettings.image_type,
|
image_type: imageSettings.image_type,
|
||||||
max_in_article_images: imageSettings.max_in_article_images,
|
max_in_article_images: imageSettings.max_in_article_images,
|
||||||
image_format: imageSettings.image_format,
|
image_format: imageSettings.image_format,
|
||||||
desktop_enabled: imageSettings.desktop_enabled,
|
|
||||||
mobile_enabled: imageSettings.mobile_enabled,
|
|
||||||
featured_image_size: imageSettings.featured_image_size,
|
featured_image_size: imageSettings.featured_image_size,
|
||||||
desktop_image_size: imageSettings.desktop_image_size,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await fetchAPI('/v1/system/settings/integrations/image_generation/save/', {
|
await fetchAPI('/v1/system/settings/integrations/image_generation/save/', {
|
||||||
@@ -1023,7 +1021,7 @@ export default function SiteSettings() {
|
|||||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-500 to-brand-500 text-white">
|
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-500 to-brand-500 text-white">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="font-medium">Featured Image Size</div>
|
<div className="font-medium">Featured Image Size</div>
|
||||||
<div className="text-xs bg-white/20 px-2 py-1 rounded">Always Enabled</div>
|
<div className="text-xs bg-white/20 px-2 py-1 rounded">Landscape (Model-specific)</div>
|
||||||
</div>
|
</div>
|
||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
options={availableImageSizes}
|
options={availableImageSizes}
|
||||||
@@ -1031,42 +1029,13 @@ export default function SiteSettings() {
|
|||||||
onChange={(value) => setImageSettings({ ...imageSettings, featured_image_size: value })}
|
onChange={(value) => 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"
|
className="w-full [&_.igny8-select-styled]:bg-white/10 [&_.igny8-select-styled]:border-white/20 [&_.igny8-select-styled]:text-white"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-white/70 mt-2">
|
||||||
|
In-article images alternate: Square (1024×1024) → Landscape → Square → Landscape
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Desktop & Mobile Images */}
|
{/* Row 3: Max Images & Format */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={imageSettings.desktop_enabled}
|
|
||||||
onChange={(checked) => setImageSettings({ ...imageSettings, desktop_enabled: checked })}
|
|
||||||
/>
|
|
||||||
<Label className="font-medium text-gray-700 dark:text-gray-300">Desktop Images</Label>
|
|
||||||
</div>
|
|
||||||
{imageSettings.desktop_enabled && (
|
|
||||||
<SelectDropdown
|
|
||||||
options={availableImageSizes}
|
|
||||||
value={imageSettings.desktop_image_size}
|
|
||||||
onChange={(value) => setImageSettings({ ...imageSettings, desktop_image_size: value })}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
||||||
<Checkbox
|
|
||||||
checked={imageSettings.mobile_enabled}
|
|
||||||
onChange={(checked) => setImageSettings({ ...imageSettings, mobile_enabled: checked })}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Label className="font-medium text-gray-700 dark:text-gray-300">Mobile Images</Label>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">512×512 pixels</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 4: Max Images & Format */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2">Max In-Article Images</Label>
|
<Label className="mb-2">Max In-Article Images</Label>
|
||||||
@@ -1076,12 +1045,14 @@ export default function SiteSettings() {
|
|||||||
{ value: '2', label: '2 Images' },
|
{ value: '2', label: '2 Images' },
|
||||||
{ value: '3', label: '3 Images' },
|
{ value: '3', label: '3 Images' },
|
||||||
{ value: '4', label: '4 Images' },
|
{ value: '4', label: '4 Images' },
|
||||||
{ value: '5', label: '5 Images' },
|
|
||||||
]}
|
]}
|
||||||
value={String(imageSettings.max_in_article_images)}
|
value={String(imageSettings.max_in_article_images)}
|
||||||
onChange={(value) => setImageSettings({ ...imageSettings, max_in_article_images: parseInt(value) })}
|
onChange={(value) => setImageSettings({ ...imageSettings, max_in_article_images: parseInt(value) })}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Images 1 & 3: Square | Images 2 & 4: Landscape
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1409,6 +1409,7 @@ export interface ImageRecord {
|
|||||||
caption?: string | null;
|
caption?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
position: number;
|
position: number;
|
||||||
|
aspect_ratio?: 'square' | 'landscape'; // square for position 0,2 | landscape for position 1,3
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
account_id?: number | null;
|
account_id?: number | null;
|
||||||
|
|||||||
@@ -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 => {
|
* ContentSectionBlock - Renders a content section with image layout based on aspect ratio
|
||||||
const parser = new DOMParser();
|
*
|
||||||
const doc = parser.parseFromString(html, 'text/html');
|
* Layout rules:
|
||||||
return doc.querySelector('table') !== null;
|
* - Single landscape image: 100% width (full width)
|
||||||
};
|
* - Single square image: 50% width (centered)
|
||||||
|
* - Two square images (paired): Side by side (50% each)
|
||||||
|
*/
|
||||||
const ContentSectionBlock = ({
|
const ContentSectionBlock = ({
|
||||||
section,
|
section,
|
||||||
image,
|
image,
|
||||||
loading,
|
loading,
|
||||||
index,
|
index,
|
||||||
imagePlacement = 'right',
|
aspectRatio = 'square',
|
||||||
firstImage = null,
|
pairedSquareImage = null,
|
||||||
}: {
|
}: {
|
||||||
section: ArticleSection;
|
section: ArticleSection;
|
||||||
image: ImageRecord | null;
|
image: ImageRecord | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
index: number;
|
index: number;
|
||||||
imagePlacement?: 'left' | 'center' | 'right';
|
aspectRatio?: 'square' | 'landscape';
|
||||||
firstImage?: ImageRecord | null;
|
pairedSquareImage?: ImageRecord | null;
|
||||||
}) => {
|
}) => {
|
||||||
const hasImage = Boolean(image);
|
const hasImage = Boolean(image);
|
||||||
|
const hasPairedImage = Boolean(pairedSquareImage);
|
||||||
const headingLabel = section.heading || `Section ${index + 1}`;
|
const headingLabel = section.heading || `Section ${index + 1}`;
|
||||||
const sectionHasTable = hasTable(section.bodyHtml);
|
|
||||||
const { beforeH3, h3AndAfter } = splitAtFirstH3(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 (
|
return (
|
||||||
<section id={section.id} className="group/section scroll-mt-24">
|
<section id={section.id} className="group/section scroll-mt-24">
|
||||||
<div className="overflow-hidden rounded-3xl border border-gray-200/80 bg-white/90 shadow-lg shadow-slate-200/50 backdrop-blur-sm transition-transform duration-300 group-hover/section:-translate-y-1 dark:border-gray-800/70 dark:bg-gray-900/70 dark:shadow-black/20">
|
<div className="overflow-hidden rounded-3xl border border-gray-200/80 bg-white/90 shadow-lg shadow-slate-200/50 backdrop-blur-sm transition-transform duration-300 group-hover/section:-translate-y-1 dark:border-gray-800/70 dark:bg-gray-900/70 dark:shadow-black/20">
|
||||||
<div className="flex flex-col gap-6 p-8 sm:p-10">
|
<div className="flex flex-col gap-6 p-8 sm:p-10">
|
||||||
|
{/* Section header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-brand-500/10 text-sm font-semibold text-brand-600 dark:bg-brand-500/20 dark:text-brand-300">
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-brand-500/10 text-sm font-semibold text-brand-600 dark:bg-brand-500/20 dark:text-brand-300">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
@@ -435,86 +451,51 @@ const ContentSectionBlock = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{imagePlacement === 'center' && hasImage ? (
|
{/* Content layout with images */}
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10">
|
||||||
{/* Content before H3 */}
|
{/* Content before H3 */}
|
||||||
{beforeH3 && (
|
{beforeH3 && (
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
<div className="content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert">
|
||||||
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
|
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Centered image before H3 */}
|
{/* Image section - layout depends on aspect ratio */}
|
||||||
|
{hasImage && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-full max-w-[60%]">
|
{hasPairedImage ? (
|
||||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
// Two squares side by side (50% each)
|
||||||
</div>
|
<div className="grid w-full grid-cols-2 gap-6">
|
||||||
|
<div className="w-full">
|
||||||
|
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<SectionImageBlock image={pairedSquareImage} loading={loading} heading={`${headingLabel} (2)`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Single image with width based on aspect ratio
|
||||||
|
<div className={getImageContainerClass()}>
|
||||||
|
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* H3 and remaining content */}
|
{/* H3 and remaining content */}
|
||||||
{h3AndAfter && (
|
{h3AndAfter && (
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
<div className="content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert">
|
||||||
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
|
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fallback if no H3 found */}
|
|
||||||
{!beforeH3 && !h3AndAfter && (
|
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : sectionHasTable && hasImage && firstImage ? (
|
|
||||||
<div className="flex flex-col gap-10">
|
|
||||||
{/* Content before H3 */}
|
|
||||||
{beforeH3 && (
|
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Two images side by side at 50% width each */}
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<div className="w-full">
|
|
||||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<SectionImageBlock image={firstImage} loading={loading} heading="First Article Image" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* H3 and remaining content */}
|
{/* Fallback if no H3 structure found */}
|
||||||
{h3AndAfter && (
|
{!beforeH3 && !h3AndAfter && (
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
<div className="content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert">
|
||||||
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fallback if no H3 found */}
|
|
||||||
{!beforeH3 && !h3AndAfter && (
|
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={hasImage ? `grid gap-10 ${imagePlacement === 'left' ? 'lg:grid-cols-[minmax(0,40%)_minmax(0,60%)]' : 'lg:grid-cols-[minmax(0,60%)_minmax(0,40%)]'}` : ''}>
|
|
||||||
{imagePlacement === 'left' && hasImage && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
||||||
</div>
|
</div>
|
||||||
{imagePlacement === 'right' && hasImage && (
|
)}
|
||||||
<div className="flex flex-col gap-4">
|
</div>
|
||||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -532,12 +513,21 @@ interface ArticleBodyProps {
|
|||||||
const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => {
|
const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => {
|
||||||
const hasStructuredSections = sections.length > 0;
|
const hasStructuredSections = sections.length > 0;
|
||||||
|
|
||||||
// Calculate image placement: right → center → left → repeat
|
// Determine image aspect ratio from record or fallback to position-based calculation
|
||||||
const getImagePlacement = (index: number): 'left' | 'center' | 'right' => {
|
// Position 0, 2 = square (1024x1024), Position 1, 3 = landscape (model-specific)
|
||||||
const position = index % 3;
|
const getImageAspectRatio = (image: ImageRecord | null, index: number): 'square' | 'landscape' => {
|
||||||
if (position === 0) return 'right';
|
if (image?.aspect_ratio) return image.aspect_ratio;
|
||||||
if (position === 1) return 'center';
|
// Fallback: even positions (0, 2) are square, odd positions (1, 3) are landscape
|
||||||
return 'left';
|
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) {
|
if (!hasStructuredSections && !introHtml && rawHtml) {
|
||||||
@@ -553,20 +543,42 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
|
|||||||
// Get the first in-article image (position 0)
|
// Get the first in-article image (position 0)
|
||||||
const firstImage = sectionImages.length > 0 ? sectionImages[0] : null;
|
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<number>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{introHtml && <IntroBlock html={introHtml} />}
|
{introHtml && <IntroBlock html={introHtml} />}
|
||||||
{sections.map((section, index) => (
|
{sections.map((section, index) => {
|
||||||
<ContentSectionBlock
|
// Skip if this image was already rendered as part of a pair
|
||||||
key={section.id || `section-${index}`}
|
if (renderedPairIndices.has(index)) {
|
||||||
section={section}
|
return null;
|
||||||
image={sectionImages[index] ?? null}
|
}
|
||||||
loading={imagesLoading}
|
|
||||||
index={index}
|
const currentImage = sectionImages[index] ?? null;
|
||||||
imagePlacement={getImagePlacement(index)}
|
const currentAspectRatio = getImageAspectRatio(currentImage, index);
|
||||||
firstImage={firstImage}
|
|
||||||
/>
|
// 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 (
|
||||||
|
<ContentSectionBlock
|
||||||
|
key={section.id || `section-${index}`}
|
||||||
|
section={section}
|
||||||
|
image={currentImage}
|
||||||
|
loading={imagesLoading}
|
||||||
|
index={index}
|
||||||
|
aspectRatio={currentAspectRatio}
|
||||||
|
pairedSquareImage={pairedSquareImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user