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
|
|
@ -282,7 +282,67 @@ export async function callMetaTool(
|
||||||
case 'create_ad_creative':
|
case 'create_ad_creative':
|
||||||
requireFields(args, ['name', 'page_id', 'link', 'message'], toolName);
|
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
|
// Asset Customization / Dynamic Creative
|
||||||
const assetFeedSpec = {
|
const assetFeedSpec = {
|
||||||
images: args.image_hashes.map((hash: any) => ({ hash })),
|
images: args.image_hashes.map((hash: any) => ({ hash })),
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,12 @@ export async function createAd(
|
||||||
return extractContent(result);
|
return extractContent(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlacementImages {
|
||||||
|
square?: string;
|
||||||
|
vertical?: string;
|
||||||
|
horizontal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAdCreative(
|
export async function createAdCreative(
|
||||||
client: Client,
|
client: Client,
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -482,6 +488,9 @@ export async function createAdCreative(
|
||||||
instagram_actor_id?: string;
|
instagram_actor_id?: string;
|
||||||
image_hash?: string;
|
image_hash?: string;
|
||||||
image_hashes?: 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;
|
link: string;
|
||||||
message: string;
|
message: string;
|
||||||
headline: string;
|
headline: string;
|
||||||
|
|
@ -496,6 +505,7 @@ export async function createAdCreative(
|
||||||
instagram_actor_id: params.instagram_actor_id,
|
instagram_actor_id: params.instagram_actor_id,
|
||||||
image_hash: params.image_hash,
|
image_hash: params.image_hash,
|
||||||
image_hashes: params.image_hashes,
|
image_hashes: params.image_hashes,
|
||||||
|
placement_images: params.placement_images,
|
||||||
link: params.link,
|
link: params.link,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
headline: params.headline,
|
headline: params.headline,
|
||||||
|
|
@ -626,19 +636,19 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
||||||
|
|
||||||
const creativeName = `${params.ad_name} — Creative`;
|
const creativeName = `${params.ad_name} — Creative`;
|
||||||
|
|
||||||
// Use a single primary image via the standard object_story_spec path —
|
// A imagem quadrada (1080x1080, a primeira gerada) é a mais compatível e vira
|
||||||
// asset_feed_spec (Asset Customization / Dynamic Creative) requires the ad
|
// o fallback universal. Quando há ≥2 imagens, tentamos também usar cada uma no
|
||||||
// set to be created with is_dynamic_creative=true, which we don't set here,
|
// seu posicionamento (1:1 feed / 9:16 stories-reels / 1.91:1 coluna direita)
|
||||||
// and combining it with degrees_of_freedom_spec triggers Meta error subcode
|
// via Placement Asset Customization (roda em ad set normal, não-dinâmico).
|
||||||
// 2490433 ("unexpected error"). The first generated image (1080x1080) is
|
|
||||||
// the most broadly compatible across placements.
|
|
||||||
const primaryImageHash = imageHashes[0];
|
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
|
// Monta o criativo com (ou sem) o Instagram e com (ou sem) customização por
|
||||||
// tem permissão pra usar a conta de IG vinculada à página, a Meta recusa o
|
// posicionamento — cada um tem seu próprio fallback (ver cascata abaixo).
|
||||||
// criativo inteiro (erro 200 / subcode 1815199). Nesse caso refazemos o
|
const buildCreative = (instagramActorId?: string, usePlacements = false) => {
|
||||||
// criativo só com o Facebook, em vez de barrar a publicação.
|
|
||||||
const buildCreative = (instagramActorId?: string) => {
|
|
||||||
if (params.is_whatsapp) {
|
if (params.is_whatsapp) {
|
||||||
return createWhatsappAdCreative(client, {
|
return createWhatsappAdCreative(client, {
|
||||||
name: creativeName,
|
name: creativeName,
|
||||||
|
|
@ -659,6 +669,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
||||||
page_id: params.page_id,
|
page_id: params.page_id,
|
||||||
instagram_actor_id: instagramActorId,
|
instagram_actor_id: instagramActorId,
|
||||||
image_hash: primaryImageHash,
|
image_hash: primaryImageHash,
|
||||||
|
placement_images: usePlacements ? placementImages : undefined,
|
||||||
link: params.link,
|
link: params.link,
|
||||||
message: params.primary_text,
|
message: params.primary_text,
|
||||||
headline: params.headline,
|
headline: params.headline,
|
||||||
|
|
@ -680,14 +691,40 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
||||||
/instagram_actor_id/i.test(err.message) ||
|
/instagram_actor_id/i.test(err.message) ||
|
||||||
/valid instagram account/i.test(err.message));
|
/valid instagram account/i.test(err.message));
|
||||||
|
|
||||||
|
const wantPlacements = !!placementImages;
|
||||||
let creative;
|
let creative;
|
||||||
let instagramDropped = false;
|
let instagramDropped = false;
|
||||||
|
let placementsDropped = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
creative = await buildCreative(params.instagram_actor_id);
|
creative = await buildCreative(params.instagram_actor_id, wantPlacements);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (params.instagram_actor_id && isInstagramError(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;
|
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 {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
@ -715,6 +752,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
||||||
creative_id: creativeId,
|
creative_id: creativeId,
|
||||||
ad_id: adId,
|
ad_id: adId,
|
||||||
instagram_dropped: instagramDropped,
|
instagram_dropped: instagramDropped,
|
||||||
|
placements_dropped: placementsDropped,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue