Require 9:16 and 1.91:1 adaptations alongside every static 4:5 ad creative.

Generation briefs and Canva webhooks now declare all three Meta placements so WF3/WF5 deliver feed, Stories, and landscape from one Freepik base.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-13 17:03:40 +02:00
parent c8608f36f5
commit 1cf8e7c1bd
12 changed files with 243 additions and 35 deletions

View File

@@ -179,7 +179,13 @@ Cambia el estado de la creatividad a `fatigada`, guarda el evento de auditoría
"variants_requested": 2,
"avoid_angles": ["objecion_precio"],
"format": "estatico",
"aspect_ratio": "4:5"
"aspect_ratio": "4:5",
"aspect_ratios": ["4:5", "9:16", "1.91:1"],
"required_placements": [
{ "aspect_ratio": "4:5", "role": "feed", "size": "1080x1350", "meta_key": "asset_url" },
{ "aspect_ratio": "9:16", "role": "stories", "size": "1080x1920", "meta_key": "stories_asset_url", "canva_template_key": "stories_9_16" },
{ "aspect_ratio": "1.91:1", "role": "landscape", "size": "1200x628", "meta_key": "landscape_asset_url", "canva_template_key": "landscape_1_91" }
]
}
}
}

View File

@@ -1,6 +1,7 @@
# Skill de formato · Estático para Meta Ads (4:5)
# Skill de formato · Estático para Meta Ads (4:5 + 9:16 + 1.91:1)
> Se aplica junto a la skill del cliente. Define la estructura de un anuncio estático de imagen para feed de Instagram y Facebook. Siempre 4:5 (1080×1350).
> Se aplica junto a la skill del cliente. Define un anuncio estático de imagen para Meta.
> **Diseño base siempre 4:5 (1080×1350).** Cada variante debe además adaptarse a **9:16** (Stories/Reels, 1080×1920) y **1.91:1** (feed landscape / right column, 1200×628).
## Qué entregas por cada variante
@@ -14,15 +15,34 @@ Un objeto JSON con exactamente estos campos:
"headline": "título del anuncio en Meta, máx. 40 caracteres",
"primary_text": "texto principal del anuncio, 2-4 frases, máx. 300 caracteres, la primera frase es el gancho",
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'",
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre y paleta",
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre vertical 4:5 y paleta",
"layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)",
"composition_notes": "instrucciones de montaje para diseño en 3-5 líneas: dónde va la franja o el texto, qué palabra va en color de acento, posición del logo, qué recortar. Es lo que ve el diseñador en la tarjeta.",
"composition_notes": "instrucciones de montaje 4:5 en 3-5 líneas: dónde va la franja o el texto, acento, logo, qué recortar",
"composition_notes_by_aspect": {
"4:5": "montaje feed vertical (misma idea que composition_notes)",
"9:16": "cómo adaptar a Stories/Reels: qué recortar, dónde subir el hook, zona segura superior/inferior de UI",
"1.91:1": "cómo adaptar a landscape: recorte horizontal, hook más corto si hace falta, franja lateral o inferior"
},
"rationale": "una frase: por qué este ángulo puede relevar a la fatigada"
}
```
Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor.
## Entrega multi-formato (obligatorio)
Por cada variante, el pipeline genera **tres piezas finales** a partir de la misma foto Freepik + hook/support:
| Ratio | Uso Meta | Tamaño | Campo de salida |
|-------|----------|--------|-----------------|
| **4:5** | Feed IG/FB (principal) | 1080×1350 | `asset_url` |
| **9:16** | Stories / Reels / Advantage+ vertical | 1080×1920 | `meta.stories_asset_url` |
| **1.91:1** | Feed landscape / right column | 1200×628 | `meta.landscape_asset_url` |
- Una sola generación de imagen Freepik en **4:5** con aire para titular.
- Canva (WF5) compone el 4:5 y **adapta** tipografía/recortes a 9:16 y 1.91:1 (mismas plantillas de marca o las keys `stories_9_16` / `landscape_1_91`).
- No inventes tres fotos distintas: es la misma creatividad en tres placements.
## Reglas de ángulo
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`).
@@ -36,6 +56,7 @@ Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 fras
- Sin signos de exclamación. Sin mayúsculas completas.
- Se lee en 1,5 segundos en móvil con el pulgar en movimiento.
- Prohibido empezar por "¿Sabías que", "Descubre", "Imagina".
- En **1.91:1** el hook puede acortarse a 5–6 palabras si no cabe con buena jerarquía.
## Reglas de copy (primary_text)
@@ -47,25 +68,28 @@ Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 fras
## Reglas de imagen (image_prompt)
- Siempre en inglés, 40-80 palabras.
- Describe: sujeto, acción, entorno, luz, encuadre (vertical 4:5, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
- Describe: sujeto, acción, entorno, luz, encuadre (**vertical 4:5**, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco.
- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta.
- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos.
- Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
- Nunca pidas personas reconocibles, marcas de terceros, ni niños.
- Una generación por variante. No pidas variaciones.
- Una generación por variante. No pidas variaciones ni tres prompts distintos por ratio.
## Reglas de layout
- Zona segura: 8 % de margen en todos los lados. Texto nunca en el 15 % superior ni inferior (lo tapan la interfaz y el CTA de Meta).
- Zona segura 4:5: 8 % de margen en todos los lados. Texto nunca en el 15 % superior ni inferior (lo tapan la interfaz y el CTA de Meta).
- 9:16: deja aire extra arriba/abajo para UI de Stories; el hook puede subir hacia el tercio superior seguro.
- 1.91:1: evita texto en los extremos laterales; prioriza franja inferior o bloque lateral corto.
- Jerarquía: hook ≥ 2× el tamaño del support.
- Contraste mínimo 4,5:1 entre texto y fondo.
- El logo ocupa como máximo el 12 % del ancho.
- El logo ocupa como máximo el 12 % del ancho (en landscape, ≤ 10 %).
## Autocheck antes de entregar
- [ ] ¿Las variantes tienen ángulos distintos entre sí y del `avoid_angles`?
- [ ] ¿Cada hook tiene ≤ 8 palabras?
- [ ] ¿El primary_text cabe en 300 caracteres?
- [ ] ¿El image_prompt no pide texto ni logo?
- [ ] ¿El image_prompt no pide texto ni logo y es 4:5?
- [ ] ¿Hay `composition_notes_by_aspect` para 4:5, 9:16 y 1.91:1?
- [ ] ¿Se respeta la lista de prohibiciones de la skill del cliente?

View File

@@ -1,6 +1,7 @@
# Skill de formato · Estático para Meta Ads (4:5)
# Skill de formato · Estático para Meta Ads (4:5 + 9:16 + 1.91:1)
> Se aplica junto a la skill del cliente. Define la estructura de un anuncio estático de imagen para feed de Instagram y Facebook. Siempre 4:5 (1080×1350).
> Se aplica junto a la skill del cliente. Define un anuncio estático de imagen para Meta.
> **Diseño base siempre 4:5 (1080×1350).** Cada variante debe además adaptarse a **9:16** (Stories/Reels, 1080×1920) y **1.91:1** (feed landscape / right column, 1200×628).
## Qué entregas por cada variante
@@ -14,15 +15,34 @@ Un objeto JSON con exactamente estos campos:
"headline": "título del anuncio en Meta, máx. 40 caracteres",
"primary_text": "texto principal del anuncio, 2-4 frases, máx. 300 caracteres, la primera frase es el gancho",
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'",
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre y paleta",
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre vertical 4:5 y paleta",
"layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)",
"composition_notes": "instrucciones de montaje para diseño en 3-5 líneas: dónde va la franja o el texto, qué palabra va en color de acento, posición del logo, qué recortar. Es lo que ve el diseñador en la tarjeta.",
"composition_notes": "instrucciones de montaje 4:5 en 3-5 líneas: dónde va la franja o el texto, acento, logo, qué recortar",
"composition_notes_by_aspect": {
"4:5": "montaje feed vertical (misma idea que composition_notes)",
"9:16": "cómo adaptar a Stories/Reels: qué recortar, dónde subir el hook, zona segura superior/inferior de UI",
"1.91:1": "cómo adaptar a landscape: recorte horizontal, hook más corto si hace falta, franja lateral o inferior"
},
"rationale": "una frase: por qué este ángulo puede relevar a la fatigada"
}
```
Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor.
## Entrega multi-formato (obligatorio)
Por cada variante, el pipeline genera **tres piezas finales** a partir de la misma foto Freepik + hook/support:
| Ratio | Uso Meta | Tamaño | Campo de salida |
|-------|----------|--------|-----------------|
| **4:5** | Feed IG/FB (principal) | 1080×1350 | `asset_url` |
| **9:16** | Stories / Reels / Advantage+ vertical | 1080×1920 | `meta.stories_asset_url` |
| **1.91:1** | Feed landscape / right column | 1200×628 | `meta.landscape_asset_url` |
- Una sola generación de imagen Freepik en **4:5** con aire para titular.
- Canva (WF5) compone el 4:5 y **adapta** tipografía/recortes a 9:16 y 1.91:1 (mismas plantillas de marca o las keys `stories_9_16` / `landscape_1_91`).
- No inventes tres fotos distintas: es la misma creatividad en tres placements.
## Reglas de ángulo
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`).
@@ -36,6 +56,7 @@ Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 fras
- Sin signos de exclamación. Sin mayúsculas completas.
- Se lee en 1,5 segundos en móvil con el pulgar en movimiento.
- Prohibido empezar por "¿Sabías que", "Descubre", "Imagina".
- En **1.91:1** el hook puede acortarse a 5–6 palabras si no cabe con buena jerarquía.
## Reglas de copy (primary_text)
@@ -47,25 +68,28 @@ Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 fras
## Reglas de imagen (image_prompt)
- Siempre en inglés, 40-80 palabras.
- Describe: sujeto, acción, entorno, luz, encuadre (vertical 4:5, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
- Describe: sujeto, acción, entorno, luz, encuadre (**vertical 4:5**, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco.
- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta.
- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos.
- Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
- Nunca pidas personas reconocibles, marcas de terceros, ni niños.
- Una generación por variante. No pidas variaciones.
- Una generación por variante. No pidas variaciones ni tres prompts distintos por ratio.
## Reglas de layout
- Zona segura: 8 % de margen en todos los lados. Texto nunca en el 15 % superior ni inferior (lo tapan la interfaz y el CTA de Meta).
- Zona segura 4:5: 8 % de margen en todos los lados. Texto nunca en el 15 % superior ni inferior (lo tapan la interfaz y el CTA de Meta).
- 9:16: deja aire extra arriba/abajo para UI de Stories; el hook puede subir hacia el tercio superior seguro.
- 1.91:1: evita texto en los extremos laterales; prioriza franja inferior o bloque lateral corto.
- Jerarquía: hook ≥ 2× el tamaño del support.
- Contraste mínimo 4,5:1 entre texto y fondo.
- El logo ocupa como máximo el 12 % del ancho.
- El logo ocupa como máximo el 12 % del ancho (en landscape, ≤ 10 %).
## Autocheck antes de entregar
- [ ] ¿Las variantes tienen ángulos distintos entre sí y del `avoid_angles`?
- [ ] ¿Cada hook tiene ≤ 8 palabras?
- [ ] ¿El primary_text cabe en 300 caracteres?
- [ ] ¿El image_prompt no pide texto ni logo?
- [ ] ¿El image_prompt no pide texto ni logo y es 4:5?
- [ ] ¿Hay `composition_notes_by_aspect` para 4:5, 9:16 y 1.91:1?
- [ ] ¿Se respeta la lista de prohibiciones de la skill del cliente?

View File

@@ -3,6 +3,7 @@ import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { staticAdBriefAspectFields } from '@/lib/creatives-aspect-ratios';
import { createClient as createAdminClient } from '@supabase/supabase-js';
function getAdmin() {
@@ -144,13 +145,16 @@ export async function POST(req: NextRequest) {
const avoidAngles = body.avoid_angles || (creativeAngle ? [creativeAngle] : []);
const format = body.format || 'estatico';
const brief = {
tenant_id: tenantId,
client_id: tenantId,
client_slug: tenant?.slug || 'cliente',
reason: body.reason || 'manual',
source_creative_id: body.source_creative_id || null,
format: body.format || 'estatico',
format,
...staticAdBriefAspectFields(format),
variants_requested: Number(body.variants_requested || 3),
campaign_name: campaignName,
adset_id: adsetId,

View File

@@ -127,16 +127,31 @@ export async function PATCH(
(existingMeta as any).raw_image_url ||
(!creative.asset_final && creative.asset_url ? creative.asset_url : undefined);
const placementMeta: Record<string, string> = {};
if (typeof body.stories_asset_url === 'string' && body.stories_asset_url) {
placementMeta.stories_asset_url = body.stories_asset_url;
}
if (typeof body.landscape_asset_url === 'string' && body.landscape_asset_url) {
placementMeta.landscape_asset_url = body.landscape_asset_url;
}
if (body.meta !== undefined && typeof body.meta === 'object' && body.meta !== null) {
updates.meta = {
...existingMeta,
...(preservedRawUrl ? { raw_image_url: preservedRawUrl } : {}),
...body.meta,
...placementMeta,
};
} else if (preservedRawUrl && body.asset_final) {
updates.meta = {
...existingMeta,
raw_image_url: preservedRawUrl,
...placementMeta,
};
} else if (Object.keys(placementMeta).length > 0) {
updates.meta = {
...existingMeta,
...placementMeta,
};
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { staticAdWebhookAspectFields } from '@/lib/creatives-aspect-ratios';
import { normalizeMetaAdCopyInput } from '@/lib/meta-ad-copy';
import { createClient } from '@supabase/supabase-js';
@@ -66,6 +67,11 @@ export async function POST(req: NextRequest) {
if (c.composition_notes) {
meta.composition_notes = c.composition_notes;
}
if (c.composition_notes_by_aspect) {
meta.composition_notes_by_aspect = c.composition_notes_by_aspect;
}
if (c.stories_asset_url) meta.stories_asset_url = c.stories_asset_url;
if (c.landscape_asset_url) meta.landscape_asset_url = c.landscape_asset_url;
if (c.asset_url && !c.asset_final && !meta.raw_image_url) {
meta.raw_image_url = c.asset_url;
}
@@ -154,6 +160,7 @@ export async function POST(req: NextRequest) {
meta: c.meta || {},
client: clientInfo,
},
...staticAdWebhookAspectFields(c.format),
});
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { staticAdBriefAspectFields } from '@/lib/creatives-aspect-ratios';
import { createClient } from '@supabase/supabase-js';
export async function POST(req: NextRequest) {
@@ -109,14 +110,18 @@ export async function POST(req: NextRequest) {
const deadline = new Date();
deadline.setDate(deadline.getDate() + 1); // 24 horas después
const format =
creative.format === 'video' || creative.format === 'carrusel'
? creative.format
: 'estatico';
const brief = {
client_id: tenantId,
tenant_id: tenantId,
client_slug: clientSlug,
reason: 'fatiga',
source_creative_id: creativeId,
format: creative.format === 'video' || creative.format === 'carrusel' ? creative.format : 'estatico',
aspect_ratio: '4:5',
format,
...staticAdBriefAspectFields(format),
variants_requested: 2,
avoid_angles: avoidAngles,
campaign_name: campaignName,

View File

@@ -791,8 +791,8 @@ export default function AgencyCreativesView({
) : null}
</div>
{/* Badge Formato + 9:16 Stories */}
<div className="absolute top-2 right-2 flex items-center gap-1">
{/* Badge Formato + placements 9:16 / 1.91:1 */}
<div className="absolute top-2 right-2 flex flex-wrap items-center justify-end gap-1 max-w-[70%]">
{c.meta?.stories_asset_url ? (
<a
href={c.meta.stories_asset_url}
@@ -802,7 +802,19 @@ export default function AgencyCreativesView({
title="Abrir versión 9:16 Stories/Reels"
className="rounded-md bg-purple-600/90 hover:bg-purple-700 backdrop-blur-xs px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
9:16 Stories ↗
9:16 ↗
</a>
) : null}
{c.meta?.landscape_asset_url ? (
<a
href={c.meta.landscape_asset_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
title="Abrir versión 1.91:1 landscape"
className="rounded-md bg-sky-600/90 hover:bg-sky-700 backdrop-blur-xs px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
1.91:1 ↗
</a>
) : null}
<span className="rounded-md bg-black/60 backdrop-blur-xs px-1.5 py-0.5 text-[10px] font-semibold text-white uppercase">
@@ -1421,7 +1433,7 @@ export default function AgencyCreativesView({
Historial y Cola de Relevos de Claude (WF3)
</h3>
<p className="text-xs text-gray-500">
Peticiones procesadas por Claude Opus 5-5 y Freepik Mystic a 4:5.
Peticiones estáticas: Freepik 4:5 + Canva adapta a 9:16 y 1.91:1.
</p>
</div>
<button
@@ -1611,7 +1623,7 @@ export default function AgencyCreativesView({
onChange={(e) => setRequestFormat(e.target.value as any)}
className="w-full rounded-lg border border-gray-300 p-2 focus:border-brand-500"
>
<option value="estatico">Estático (4:5 / 2K)</option>
<option value="estatico">Estático (4:5 + 9:16 + 1.91:1)</option>
<option value="carrusel">Carrusel</option>
<option value="video">Vídeo</option>
</select>

View File

@@ -127,6 +127,7 @@ export default function ClientCreativeStylePanel({
tipografico?: string;
'dato-grande'?: string;
stories_9_16?: string;
landscape_1_91?: string;
default?: string;
}>({});
const [campaignRows, setCampaignRows] = useState<CampaignRow[]>([]);
@@ -1716,9 +1717,9 @@ export default function ClientCreativeStylePanel({
{/* Plantillas de Canva */}
<div className="space-y-3 pt-2 border-t border-gray-200">
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
Plantillas de Marca en Canva (Brand Template IDs para WF5 · Feed 4:5 y Stories 9:16)
Plantillas Canva WF5 · 4:5 feed + 9:16 Stories + 1.91:1 landscape
</h4>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
4:5 · foto-con-franja
@@ -1783,10 +1784,27 @@ export default function ClientCreativeStylePanel({
stories_9_16: e.target.value.trim(),
})
}
placeholder="EAH... (Opcional 9:16)"
placeholder="EAH... (9:16)"
className="w-full rounded-lg border border-brand-300 bg-brand-50/30 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-sky-800 mb-1">
1.91:1 · Landscape
</label>
<input
type="text"
value={canvaTemplates.landscape_1_91 || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
landscape_1_91: e.target.value.trim(),
})
}
placeholder="EAH... (1.91:1)"
className="w-full rounded-lg border border-sky-300 bg-sky-50/40 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Fallback por defecto

View File

@@ -0,0 +1,76 @@
/**
* Aspect ratios for Meta static ads.
* Primary design is always 4:5; every estatico request must also adapt to Stories (9:16)
* and landscape feed / right column (1.91:1).
*/
export const STATIC_PRIMARY_ASPECT = '4:5' as const;
export const STATIC_AD_ASPECT_RATIOS = ['4:5', '9:16', '1.91:1'] as const;
export type StaticAdAspectRatio = (typeof STATIC_AD_ASPECT_RATIOS)[number];
export type StaticAdPlacement = {
aspect_ratio: StaticAdAspectRatio;
role: 'feed' | 'stories' | 'landscape';
size: string;
/** Where n8n should store the final URL on the creative */
meta_key: 'asset_url' | 'stories_asset_url' | 'landscape_asset_url';
canva_template_key?: string;
};
export const STATIC_AD_PLACEMENTS: StaticAdPlacement[] = [
{
aspect_ratio: '4:5',
role: 'feed',
size: '1080x1350',
meta_key: 'asset_url',
},
{
aspect_ratio: '9:16',
role: 'stories',
size: '1080x1920',
meta_key: 'stories_asset_url',
canva_template_key: 'stories_9_16',
},
{
aspect_ratio: '1.91:1',
role: 'landscape',
size: '1200x628',
meta_key: 'landscape_asset_url',
canva_template_key: 'landscape_1_91',
},
];
export function isStaticAdFormat(format?: string | null): boolean {
const f = String(format || 'estatico').toLowerCase().trim();
return f === 'estatico' || f === 'image' || f === 'static';
}
/** Fields to merge into generation_request.brief for static ads. */
export function staticAdBriefAspectFields(format?: string | null): {
aspect_ratio?: string;
aspect_ratios?: string[];
required_placements?: StaticAdPlacement[];
} {
if (!isStaticAdFormat(format)) {
return {};
}
return {
aspect_ratio: STATIC_PRIMARY_ASPECT,
aspect_ratios: [...STATIC_AD_ASPECT_RATIOS],
required_placements: STATIC_AD_PLACEMENTS,
};
}
/** Extra payload for creative.created / recompose so WF5 composes all placements. */
export function staticAdWebhookAspectFields(format?: string | null) {
if (!isStaticAdFormat(format)) {
return {};
}
return {
aspect_ratio: STATIC_PRIMARY_ASPECT,
aspect_ratios: [...STATIC_AD_ASPECT_RATIOS],
required_placements: STATIC_AD_PLACEMENTS,
};
}

View File

@@ -110,7 +110,17 @@ export interface GenerationRequestBrief {
reason: GenerationRequestReason;
source_creative_id?: string | null;
format: 'estatico' | 'carrusel' | 'video';
/** Primary design ratio (always 4:5 for estático). */
aspect_ratio?: string;
/** All placements to produce for estático: 4:5, 9:16, 1.91:1. */
aspect_ratios?: string[];
required_placements?: Array<{
aspect_ratio: string;
role: string;
size: string;
meta_key: string;
canva_template_key?: string;
}>;
variants_requested: number;
avoid_angles?: string[];
campaign_name?: string | null;

View File

@@ -14,6 +14,10 @@ import {
buildPartialRegenBriefNotes,
type CreativeRegenSelection,
} from './creatives-regen';
import {
staticAdBriefAspectFields,
staticAdWebhookAspectFields,
} from './creatives-aspect-ratios';
export interface ActionActor {
role: 'agency' | 'client' | string;
@@ -346,14 +350,15 @@ export async function rejectCreative(
const landingUrl =
prodEntry?.landing_url || creative.landing_url || tenant?.default_landing_url || null;
const format = creative.format || 'estatico';
const briefPayload = {
client_id: tenantId,
tenant_id: tenantId,
client_slug: tenant?.slug || 'bps',
reason: 'manual',
source_creative_id: sourceId,
format: creative.format || 'estatico',
aspect_ratio: '4:5',
format,
...staticAdBriefAspectFields(format),
variants_requested: options?.variantsRequested || 1,
avoid_angles: avoidAngles,
campaign_name: campaignName,
@@ -639,14 +644,15 @@ export async function requestCreativePartialV2(
.filter(Boolean)
.join('\n\n');
const format = creative.format || 'estatico';
const briefPayload = {
client_id: tenantId,
tenant_id: tenantId,
client_slug: tenant?.slug || 'bps',
reason: 'manual',
source_creative_id: creative.id,
format: creative.format || 'estatico',
aspect_ratio: '4:5',
format,
...staticAdBriefAspectFields(format),
variants_requested: 1,
avoid_angles: plan.regenerate.image && creative.angle ? [creative.angle] : [],
campaign_name: campaignName,
@@ -824,7 +830,7 @@ export async function recomposeCreativeInCanva(
visibility: 'agency',
});
// Disparar WF5 pasando la imagen limpia original (raw_image_url) como asset_url para Canva
// Disparar WF5: componer 4:5 + adaptar 9:16 y 1.91:1
dispatchN8nWebhookAsync('creative.created', {
creative: {
...updated,
@@ -833,6 +839,7 @@ export async function recomposeCreativeInCanva(
meta: existingMeta,
client: clientInfo,
},
...staticAdWebhookAspectFields(updated.format),
});
return updated;