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

@@ -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;