feat: usar as 3 imagens por posicionamento (placement customization)
Antes o criativo usava só a imagem quadrada. Agora, quando há >=2 imagens geradas, monta o criativo com Placement Asset Customization: 1:1 no feed, 9:16 em stories/reels e 1.91:1 na coluna direita — em ad set normal (não-dinâmico), com object_story_spec mantendo page_id/IG. Cascata de fallback segura: se a Meta recusar a customização, refaz com a imagem única (comportamento antigo estável); se recusar o IG, refaz só com Facebook. Publicação nunca quebra por causa disso. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f2d3f34bbf
commit
6068f77b09
2 changed files with 113 additions and 15 deletions
|
|
@ -281,8 +281,68 @@ export async function callMetaTool(
|
|||
|
||||
case 'create_ad_creative':
|
||||
requireFields(args, ['name', 'page_id', 'link', 'message'], toolName);
|
||||
|
||||
if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) {
|
||||
|
||||
// Placement Asset Customization: imagem específica por posicionamento
|
||||
// (1:1 no feed, 9:16 em stories/reels, 1.91:1 na coluna direita). Roda
|
||||
// em ad set normal (não-dinâmico). Mantém object_story_spec com page_id
|
||||
// (e IG) + asset_feed_spec com regras de customização por placement.
|
||||
if (args.placement_images && (args.placement_images.vertical || args.placement_images.horizontal)) {
|
||||
const pi = args.placement_images as { square?: string; vertical?: string; horizontal?: string };
|
||||
// O branch garante que ao menos vertical/horizontal existe.
|
||||
const squareHash = (pi.square || pi.vertical || pi.horizontal) as string;
|
||||
|
||||
const images: Array<{ hash: string; adlabels: Array<{ name: string }> }> = [
|
||||
{ hash: squareHash, adlabels: [{ name: 'sq' }] },
|
||||
];
|
||||
const rules: Array<Record<string, unknown>> = [
|
||||
// Regra padrão (mais ampla): cobre todos os posicionamentos.
|
||||
{
|
||||
customization_spec: { publisher_platforms: ['facebook', 'instagram', 'audience_network', 'messenger'] },
|
||||
image_label: { name: 'sq' },
|
||||
},
|
||||
];
|
||||
|
||||
if (pi.vertical && pi.vertical !== squareHash) {
|
||||
images.push({ hash: pi.vertical, adlabels: [{ name: 'vt' }] });
|
||||
rules.push({
|
||||
customization_spec: {
|
||||
publisher_platforms: ['facebook', 'instagram'],
|
||||
facebook_positions: ['story', 'facebook_reels'],
|
||||
instagram_positions: ['story', 'reels'],
|
||||
},
|
||||
image_label: { name: 'vt' },
|
||||
});
|
||||
}
|
||||
|
||||
if (pi.horizontal && pi.horizontal !== squareHash) {
|
||||
images.push({ hash: pi.horizontal, adlabels: [{ name: 'hz' }] });
|
||||
rules.push({
|
||||
customization_spec: {
|
||||
publisher_platforms: ['facebook'],
|
||||
facebook_positions: ['right_hand_column'],
|
||||
},
|
||||
image_label: { name: 'hz' },
|
||||
});
|
||||
}
|
||||
|
||||
const objectStorySpec: any = { page_id: args.page_id };
|
||||
if (args.instagram_actor_id) objectStorySpec.instagram_actor_id = args.instagram_actor_id;
|
||||
|
||||
resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, {
|
||||
name: args.name,
|
||||
object_story_spec: objectStorySpec,
|
||||
asset_feed_spec: {
|
||||
images,
|
||||
bodies: [{ text: args.message }],
|
||||
titles: [{ text: args.headline || 'Headline' }],
|
||||
descriptions: [{ text: args.description || '' }],
|
||||
link_urls: [{ website_url: args.link }],
|
||||
call_to_action_types: [args.call_to_action_type || 'LEARN_MORE'],
|
||||
ad_formats: ['SINGLE_IMAGE'],
|
||||
asset_customization_rules: rules,
|
||||
},
|
||||
});
|
||||
} else if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) {
|
||||
// Asset Customization / Dynamic Creative
|
||||
const assetFeedSpec = {
|
||||
images: args.image_hashes.map((hash: any) => ({ hash })),
|
||||
|
|
|
|||
|
|
@ -474,6 +474,12 @@ export async function createAd(
|
|||
return extractContent(result);
|
||||
}
|
||||
|
||||
export interface PlacementImages {
|
||||
square?: string;
|
||||
vertical?: string;
|
||||
horizontal?: string;
|
||||
}
|
||||
|
||||
export async function createAdCreative(
|
||||
client: Client,
|
||||
params: {
|
||||
|
|
@ -482,6 +488,9 @@ export async function createAdCreative(
|
|||
instagram_actor_id?: string;
|
||||
image_hash?: string;
|
||||
image_hashes?: string[];
|
||||
// Imagens por posicionamento (1:1 / 9:16 / 1.91:1). Quando presente, o
|
||||
// criativo usa Placement Asset Customization em vez de uma imagem única.
|
||||
placement_images?: PlacementImages;
|
||||
link: string;
|
||||
message: string;
|
||||
headline: string;
|
||||
|
|
@ -496,6 +505,7 @@ export async function createAdCreative(
|
|||
instagram_actor_id: params.instagram_actor_id,
|
||||
image_hash: params.image_hash,
|
||||
image_hashes: params.image_hashes,
|
||||
placement_images: params.placement_images,
|
||||
link: params.link,
|
||||
message: params.message,
|
||||
headline: params.headline,
|
||||
|
|
@ -626,19 +636,19 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
|||
|
||||
const creativeName = `${params.ad_name} — Creative`;
|
||||
|
||||
// Use a single primary image via the standard object_story_spec path —
|
||||
// asset_feed_spec (Asset Customization / Dynamic Creative) requires the ad
|
||||
// set to be created with is_dynamic_creative=true, which we don't set here,
|
||||
// and combining it with degrees_of_freedom_spec triggers Meta error subcode
|
||||
// 2490433 ("unexpected error"). The first generated image (1080x1080) is
|
||||
// the most broadly compatible across placements.
|
||||
// A imagem quadrada (1080x1080, a primeira gerada) é a mais compatível e vira
|
||||
// o fallback universal. Quando há ≥2 imagens, tentamos também usar cada uma no
|
||||
// seu posicionamento (1:1 feed / 9:16 stories-reels / 1.91:1 coluna direita)
|
||||
// via Placement Asset Customization (roda em ad set normal, não-dinâmico).
|
||||
const primaryImageHash = imageHashes[0];
|
||||
const placementImages: PlacementImages | undefined =
|
||||
!params.is_whatsapp && imageHashes.length >= 2
|
||||
? { square: imageHashes[0], vertical: imageHashes[1], horizontal: imageHashes[2] }
|
||||
: undefined;
|
||||
|
||||
// Monta o criativo com (ou sem) o Instagram. Quando a conta de anúncios não
|
||||
// tem permissão pra usar a conta de IG vinculada à página, a Meta recusa o
|
||||
// criativo inteiro (erro 200 / subcode 1815199). Nesse caso refazemos o
|
||||
// criativo só com o Facebook, em vez de barrar a publicação.
|
||||
const buildCreative = (instagramActorId?: string) => {
|
||||
// Monta o criativo com (ou sem) o Instagram e com (ou sem) customização por
|
||||
// posicionamento — cada um tem seu próprio fallback (ver cascata abaixo).
|
||||
const buildCreative = (instagramActorId?: string, usePlacements = false) => {
|
||||
if (params.is_whatsapp) {
|
||||
return createWhatsappAdCreative(client, {
|
||||
name: creativeName,
|
||||
|
|
@ -659,6 +669,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
|||
page_id: params.page_id,
|
||||
instagram_actor_id: instagramActorId,
|
||||
image_hash: primaryImageHash,
|
||||
placement_images: usePlacements ? placementImages : undefined,
|
||||
link: params.link,
|
||||
message: params.primary_text,
|
||||
headline: params.headline,
|
||||
|
|
@ -680,14 +691,40 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
|||
/instagram_actor_id/i.test(err.message) ||
|
||||
/valid instagram account/i.test(err.message));
|
||||
|
||||
const wantPlacements = !!placementImages;
|
||||
let creative;
|
||||
let instagramDropped = false;
|
||||
let placementsDropped = false;
|
||||
|
||||
try {
|
||||
creative = await buildCreative(params.instagram_actor_id);
|
||||
creative = await buildCreative(params.instagram_actor_id, wantPlacements);
|
||||
} catch (err) {
|
||||
if (params.instagram_actor_id && isInstagramError(err)) {
|
||||
creative = await buildCreative(undefined);
|
||||
// Problema no Instagram: refaz sem IG (mantendo a customização se houver).
|
||||
instagramDropped = true;
|
||||
try {
|
||||
creative = await buildCreative(undefined, wantPlacements);
|
||||
} catch (err2) {
|
||||
if (wantPlacements) {
|
||||
placementsDropped = true;
|
||||
creative = await buildCreative(undefined, false);
|
||||
} else {
|
||||
throw err2;
|
||||
}
|
||||
}
|
||||
} else if (wantPlacements) {
|
||||
// Erro provável da customização por posicionamento → cai pra imagem única.
|
||||
placementsDropped = true;
|
||||
try {
|
||||
creative = await buildCreative(params.instagram_actor_id, false);
|
||||
} catch (err2) {
|
||||
if (params.instagram_actor_id && isInstagramError(err2)) {
|
||||
instagramDropped = true;
|
||||
creative = await buildCreative(undefined, false);
|
||||
} else {
|
||||
throw err2;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -715,6 +752,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
|||
creative_id: creativeId,
|
||||
ad_id: adId,
|
||||
instagram_dropped: instagramDropped,
|
||||
placements_dropped: placementsDropped,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue