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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
76
src/lib/creatives-aspect-ratios.ts
Normal file
76
src/lib/creatives-aspect-ratios.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user