adyou 0.6.18 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/google.d.ts +5 -0
- package/dist/adapters/google.js +16 -1
- package/dist/adapters/meta-targeting.d.ts +37 -0
- package/dist/adapters/meta-targeting.js +66 -0
- package/dist/adapters/meta.js +25 -6
- package/dist/adapters/types.d.ts +8 -1
- package/dist/cli.js +2 -2
- package/dist/core/creatives/factory.d.ts +21 -0
- package/dist/core/creatives/factory.js +124 -2
- package/dist/core/creatives/media.d.ts +6 -1
- package/dist/core/creatives/media.js +7 -4
- package/dist/core/creatives/videofx.d.ts +5 -0
- package/dist/core/creatives/videofx.js +6 -0
- package/dist/core/ops.d.ts +4 -2
- package/dist/core/ops.js +25 -5
- package/dist/core/optimize.d.ts +47 -1
- package/dist/core/optimize.js +89 -25
- package/dist/core/state.d.ts +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -14,6 +14,11 @@ export declare class GoogleConnector implements Connector {
|
|
|
14
14
|
private search;
|
|
15
15
|
listAccessible(): Promise<string[]>;
|
|
16
16
|
account(): Promise<AccountInfo>;
|
|
17
|
+
/** 이 커넥터의 customerId(= MCC · loginCustomerId 도 MCC) 아래 하위 광고 계정 만들기 — 대행 모드 고객당 계정. 실측(2026-09-25): SA 가 MCC 에서 ADMIN 이어야 함(STANDARD 면 ACTION_NOT_PERMITTED) ·
|
|
18
|
+
* 계정은 삭제 불가(화면에서 해지만) · 🔴 결제 연결은 API 불가(billingSetups → NO_SIGNUP_PERMISSION · SA 는 결제 프로필 사용자가 될 수 없음) → 운영자가 화면에서 결제 프로필을 붙인다. */
|
|
19
|
+
createClientAccount(name: string, currency?: string, timeZone?: string): Promise<string>;
|
|
20
|
+
/** 결제가 연결돼 광고가 나갈 수 있는 계정인지(billing_setup APPROVED) */
|
|
21
|
+
billingApproved(): Promise<boolean>;
|
|
17
22
|
check(): Promise<CheckItem[]>;
|
|
18
23
|
inventory(): Promise<InventoryRow[]>;
|
|
19
24
|
private uploadImage;
|
package/dist/adapters/google.js
CHANGED
|
@@ -34,7 +34,8 @@ export class GoogleConnector {
|
|
|
34
34
|
const headers = { authorization: `Bearer ${await this.accessToken()}`, 'content-type': 'application/json' };
|
|
35
35
|
if (this.c.loginCustomerId)
|
|
36
36
|
headers['login-customer-id'] = this.c.loginCustomerId;
|
|
37
|
-
|
|
37
|
+
// `:createCustomerClient` 처럼 콜론으로 시작하는 메서드는 고객 경로에 바로 붙는다(슬래시 없음)
|
|
38
|
+
const res = await fetch(`${API}/customers/${cid}${path.startsWith(':') ? '' : '/'}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000) });
|
|
38
39
|
const data = (await res.json().catch(() => ({})));
|
|
39
40
|
if (!res.ok || data.error)
|
|
40
41
|
throw this.wrap(data.error, path);
|
|
@@ -82,6 +83,20 @@ export class GoogleConnector {
|
|
|
82
83
|
throw new MediumError('google', '고객 정보를 읽지 못했어요');
|
|
83
84
|
return { id: c.id, name: c.descriptiveName || c.id, currency: c.currencyCode, timezone: c.timeZone, status: c.status, extra: { manager: c.manager } };
|
|
84
85
|
}
|
|
86
|
+
/** 이 커넥터의 customerId(= MCC · loginCustomerId 도 MCC) 아래 하위 광고 계정 만들기 — 대행 모드 고객당 계정. 실측(2026-09-25): SA 가 MCC 에서 ADMIN 이어야 함(STANDARD 면 ACTION_NOT_PERMITTED) ·
|
|
87
|
+
* 계정은 삭제 불가(화면에서 해지만) · 🔴 결제 연결은 API 불가(billingSetups → NO_SIGNUP_PERMISSION · SA 는 결제 프로필 사용자가 될 수 없음) → 운영자가 화면에서 결제 프로필을 붙인다. */
|
|
88
|
+
async createClientAccount(name, currency = 'KRW', timeZone = 'Asia/Seoul') {
|
|
89
|
+
const d = await this.call(':createCustomerClient', { customerClient: { descriptiveName: name.slice(0, 100), currencyCode: currency, timeZone } });
|
|
90
|
+
const id = d.resourceName?.split('/')[1];
|
|
91
|
+
if (!id)
|
|
92
|
+
throw new MediumError('google', '하위 계정 ID 를 받지 못했어요');
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
/** 결제가 연결돼 광고가 나갈 수 있는 계정인지(billing_setup APPROVED) */
|
|
96
|
+
async billingApproved() {
|
|
97
|
+
const rows = await this.search('SELECT billing_setup.status FROM billing_setup');
|
|
98
|
+
return rows.some((r) => r.billingSetup.status === 'APPROVED');
|
|
99
|
+
}
|
|
85
100
|
async check() {
|
|
86
101
|
const items = [];
|
|
87
102
|
let accessible = [];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type AudienceHint = {
|
|
2
|
+
ageRange?: string;
|
|
3
|
+
gender?: string;
|
|
4
|
+
};
|
|
5
|
+
export type TargetingInput = {
|
|
6
|
+
countries: string[];
|
|
7
|
+
lang?: string;
|
|
8
|
+
audience?: AudienceHint;
|
|
9
|
+
strict?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type MetaTargeting = {
|
|
12
|
+
geo_locations: {
|
|
13
|
+
countries: string[];
|
|
14
|
+
};
|
|
15
|
+
age_min: number;
|
|
16
|
+
age_max?: number;
|
|
17
|
+
genders?: number[];
|
|
18
|
+
locales?: number[];
|
|
19
|
+
targeting_automation: {
|
|
20
|
+
advantage_audience: 0 | 1;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type TargetingResult = {
|
|
24
|
+
targeting: MetaTargeting;
|
|
25
|
+
regionalCategories: string[];
|
|
26
|
+
notes: string[];
|
|
27
|
+
};
|
|
28
|
+
/** 나라 기본 언어 — 시장 언어가 이것과 다르면 「그 나라에 사는 다른 언어 사용자」(디아스포라) 로 보고 locales 를 건다 */
|
|
29
|
+
export declare const COUNTRY_LANG: Record<string, string>;
|
|
30
|
+
/** 메타 광고 locale id — 「전체」 묶음이 있는 언어는 그것(영어 1001 · 중국어 1004 · 스페인어 1002 · 포르투갈어 1003) */
|
|
31
|
+
export declare const META_LOCALE: Record<string, number[]>;
|
|
32
|
+
/** "25-34" · "18~34" · "20대" · "25+" · "30s" → [min, max] (18~65 로 자름 · 못 읽으면 null) */
|
|
33
|
+
export declare function parseAgeRange(s?: string): {
|
|
34
|
+
min: number;
|
|
35
|
+
max?: number;
|
|
36
|
+
} | null;
|
|
37
|
+
export declare function metaTargeting(i: TargetingInput): TargetingResult;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** 나라 기본 언어 — 시장 언어가 이것과 다르면 「그 나라에 사는 다른 언어 사용자」(디아스포라) 로 보고 locales 를 건다 */
|
|
2
|
+
export const COUNTRY_LANG = {
|
|
3
|
+
KR: 'ko', JP: 'ja', CN: 'zh', TW: 'zh', HK: 'zh', MO: 'zh', SG: 'en', MY: 'ms', US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en',
|
|
4
|
+
DE: 'de', AT: 'de', CH: 'de', FR: 'fr', BE: 'fr', ES: 'es', MX: 'es', AR: 'es', CL: 'es', CO: 'es', PE: 'es', IT: 'it', BR: 'pt', PT: 'pt',
|
|
5
|
+
TH: 'th', VN: 'vi', ID: 'id', RU: 'ru', NL: 'nl', PL: 'pl', TR: 'tr', SE: 'sv', NO: 'no', DK: 'da', FI: 'fi', IN: 'en', PH: 'en',
|
|
6
|
+
};
|
|
7
|
+
/** 메타 광고 locale id — 「전체」 묶음이 있는 언어는 그것(영어 1001 · 중국어 1004 · 스페인어 1002 · 포르투갈어 1003) */
|
|
8
|
+
export const META_LOCALE = {
|
|
9
|
+
ko: [12], ja: [11], zh: [1004], en: [1001], es: [1002], pt: [1003], de: [5], fr: [9], it: [10], th: [35], vi: [27], id: [25], ru: [17], nl: [14], pl: [15], tr: [19], sv: [26], da: [33], fi: [37], no: [34],
|
|
10
|
+
};
|
|
11
|
+
const REGULATED = { TW: 'TAIWAN_UNIVERSAL', SG: 'SINGAPORE_UNIVERSAL' };
|
|
12
|
+
/** "25-34" · "18~34" · "20대" · "25+" · "30s" → [min, max] (18~65 로 자름 · 못 읽으면 null) */
|
|
13
|
+
export function parseAgeRange(s) {
|
|
14
|
+
if (!s)
|
|
15
|
+
return null;
|
|
16
|
+
const t = s.replace(/\s/g, '');
|
|
17
|
+
let m = t.match(/(\d{2})\s*[-~–—]\s*(\d{2})/);
|
|
18
|
+
if (m) {
|
|
19
|
+
const a = Number(m[1]), b = Number(m[2]);
|
|
20
|
+
return { min: clamp(Math.min(a, b)), max: clamp(Math.max(a, b)) };
|
|
21
|
+
}
|
|
22
|
+
m = t.match(/(\d{2})\+/);
|
|
23
|
+
if (m)
|
|
24
|
+
return { min: clamp(Number(m[1])) };
|
|
25
|
+
m = t.match(/(\d)0(대|s)/);
|
|
26
|
+
if (m) {
|
|
27
|
+
const d = Number(m[1]) * 10;
|
|
28
|
+
return { min: clamp(d), max: clamp(d + 9) };
|
|
29
|
+
}
|
|
30
|
+
m = t.match(/^(\d{2})$/);
|
|
31
|
+
if (m)
|
|
32
|
+
return { min: clamp(Number(m[1])) };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const clamp = (n) => Math.max(18, Math.min(65, n));
|
|
36
|
+
export function metaTargeting(i) {
|
|
37
|
+
const countries = [...new Set(i.countries.map((c) => c.toUpperCase()))];
|
|
38
|
+
const notes = [];
|
|
39
|
+
const t = { geo_locations: { countries }, age_min: 18, targeting_automation: { advantage_audience: 1 } };
|
|
40
|
+
const lang = (i.lang || '').split('-')[0];
|
|
41
|
+
// ① 디아스포라 → locales(그 언어를 쓰는 사용자만). 나라 전부가 그 언어를 기본으로 쓰면 걸지 않는다(도달 손실만 있음).
|
|
42
|
+
if (lang && countries.some((c) => (COUNTRY_LANG[c] || '') !== lang) && META_LOCALE[lang]) {
|
|
43
|
+
t.locales = META_LOCALE[lang];
|
|
44
|
+
notes.push(`${countries.filter((c) => (COUNTRY_LANG[c] || '') !== lang).join('+')}의 ${lang} 사용자만(언어 설정 기준)`);
|
|
45
|
+
}
|
|
46
|
+
// ② 규제 카테고리(대만·싱가포르 보편 규제 · 없으면 광고세트 생성이 거절됨)
|
|
47
|
+
const regionalCategories = countries.map((c) => REGULATED[c]).filter(Boolean);
|
|
48
|
+
// ③ 브리프 오디언스 → 연령·성별
|
|
49
|
+
const age = parseAgeRange(i.audience?.ageRange);
|
|
50
|
+
if (age)
|
|
51
|
+
t.age_min = age.min;
|
|
52
|
+
const g = (i.audience?.gender || '').toLowerCase();
|
|
53
|
+
if (i.strict) {
|
|
54
|
+
t.targeting_automation = { advantage_audience: 0 };
|
|
55
|
+
if (age?.max)
|
|
56
|
+
t.age_max = age.max;
|
|
57
|
+
if (g === 'female' || g === '여' || g === '여성' || g === 'f')
|
|
58
|
+
t.genders = [2];
|
|
59
|
+
else if (g === 'male' || g === '남' || g === '남성' || g === 'm')
|
|
60
|
+
t.genders = [1];
|
|
61
|
+
notes.push(`타겟 고정: ${t.age_min}${t.age_max ? `~${t.age_max}` : '+'}세${t.genders ? (t.genders[0] === 2 ? ' · 여성' : ' · 남성') : ''}(Advantage+ 오디언스 끔)`);
|
|
62
|
+
}
|
|
63
|
+
else if (age)
|
|
64
|
+
notes.push(`최소 연령 ${age.min}세(Advantage+ 오디언스 · 상한은 매체가 넓힘${age.max ? ` · 브리프 상한 ${age.max}세는 strict 에서만 강제` : ''})`);
|
|
65
|
+
return { targeting: t, regionalCategories, notes };
|
|
66
|
+
}
|
package/dist/adapters/meta.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 예산=계정 통화 최소 단위 · Advantage+ 오디언스에선 age_max 금지 · DCO 광고세트엔 광고 1개(컨셉=광고세트) · instagram_user_id · WITH_ISSUES 2446984는 조치 불필요
|
|
3
3
|
// 앱 개발 모드면 크리에이티브 생성 100 오류 · 픽셀 트래픽 권한 밖 도메인은 이벤트 조용히 폐기(signals/config blockReason)
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import { metaTargeting } from './meta-targeting.js';
|
|
5
6
|
import { abKey, langGroups, localizedCopy, MediumError, nameTag, utm } from './types.js';
|
|
6
7
|
const G = 'https://graph.facebook.com/v23.0';
|
|
7
8
|
const REQUIRED_SCOPES = ['ads_management', 'ads_read', 'business_management', 'pages_show_list', 'pages_read_engagement', 'pages_manage_ads'];
|
|
@@ -200,8 +201,24 @@ export class MetaConnector {
|
|
|
200
201
|
placements.push(camp);
|
|
201
202
|
log(` meta 캠페인 ${found ? '재사용' : '생성'} ${id}`);
|
|
202
203
|
}
|
|
203
|
-
// 타겟:
|
|
204
|
-
const
|
|
204
|
+
// 타겟: 언어 그룹별 — 디아스포라 locales · 대만/싱가포르 규제 카테고리 · 브리프 오디언스(meta-targeting.ts · 밈튜브 하네스 학습 2026-09-23)
|
|
205
|
+
const targetFor = (g) => metaTargeting({ countries: g.countries, lang: g.lang, audience: input.audience, strict: input.strictAudience });
|
|
206
|
+
/** 광고세트 생성 — locales id 가 거절되면 locales 없이 1회 재시도(도달만 넓어짐 · 생성은 성공) */
|
|
207
|
+
const createAdset = async (body, tr) => {
|
|
208
|
+
const base = { ...body, targeting: tr.targeting, ...(tr.regionalCategories.length ? { regional_regulated_categories: tr.regionalCategories } : {}) };
|
|
209
|
+
try {
|
|
210
|
+
return (await this.call('POST', `${this.c.adAccountId}/adsets`, base)).id;
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (tr.targeting.locales && /locale/i.test(e instanceof Error ? e.message : String(e))) {
|
|
214
|
+
const { locales: _l, ...rest } = tr.targeting;
|
|
215
|
+
void _l;
|
|
216
|
+
log(' ⚠ 언어(locale) 타겟이 거절돼 언어 없이 만들어요');
|
|
217
|
+
return (await this.call('POST', `${this.c.adAccountId}/adsets`, { ...base, targeting: rest })).id;
|
|
218
|
+
}
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
205
222
|
const domain = new URL(input.landingUrl).hostname;
|
|
206
223
|
// 언어 그룹 — 같은 언어를 쓰는 나라끼리 광고세트를 나눠 그 언어 문구·자막 소재만 넣는다(언어 섞임 방지 · 시장별 성과 비교)
|
|
207
224
|
const groups = langGroups(input.markets, input.brief.language);
|
|
@@ -217,14 +234,16 @@ export class MetaConnector {
|
|
|
217
234
|
warnings.push(`${c.key}${lt}: 메타용 소재가 없어 건너뜀`);
|
|
218
235
|
continue;
|
|
219
236
|
}
|
|
220
|
-
const
|
|
237
|
+
const gT = targetFor(g);
|
|
238
|
+
if (c === input.concepts[0] && gT.notes.length)
|
|
239
|
+
log(` 타겟 ${g.countries.join('+')}: ${gT.notes.join(' · ')}${gT.regionalCategories.length ? ` · 규제 카테고리 ${gT.regionalCategories.join(',')}` : ''}`);
|
|
221
240
|
const gl = groups.length > 1 ? L : undefined;
|
|
222
241
|
if (imgs.length) {
|
|
223
242
|
let adset = has('adset', c.key, 'image', gl);
|
|
224
243
|
if (!adset) {
|
|
225
244
|
const name = `${tag} ${c.key} · ${g.countries.join('+')} · DCO`;
|
|
226
245
|
const found = await this.findByName('adsets', name);
|
|
227
|
-
const id = found?.id || (await
|
|
246
|
+
const id = found?.id || (await createAdset({ name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) }, gT));
|
|
228
247
|
adset = { medium: 'meta', kind: 'adset', concept: c.key, ...(gl ? { lang: gl } : {}), externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
229
248
|
placements.push(adset);
|
|
230
249
|
log(` meta 광고세트 ${c.key}${lt} ${found ? '재사용' : '생성'} ${id}`);
|
|
@@ -260,7 +279,7 @@ export class MetaConnector {
|
|
|
260
279
|
if (!adset) {
|
|
261
280
|
const name = `${tag} ${c.key} · ${g.countries.join('+')} · VIDEO`;
|
|
262
281
|
const found = await this.findByName('adsets', name);
|
|
263
|
-
const id = found?.id || (await
|
|
282
|
+
const id = found?.id || (await createAdset({ name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) }, gT));
|
|
264
283
|
adset = { medium: 'meta', kind: 'adset', concept: c.key, format: 'video', ...(gl ? { lang: gl } : {}), externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
265
284
|
placements.push(adset);
|
|
266
285
|
log(` meta 영상 광고세트 ${c.key}${lt} ${found ? '재사용' : '생성'} ${id}`);
|
|
@@ -334,7 +353,7 @@ export class MetaConnector {
|
|
|
334
353
|
const acc = await this.account();
|
|
335
354
|
const d = await this.call('GET', `${this.c.adAccountId}/insights`, { level: 'ad', fields: 'ad_id,ad_name,adset_id,spend,impressions,clicks,actions,video_play_actions,video_thruplay_watched_actions,video_p50_watched_actions', time_range: { since: range.since, until: range.until }, time_increment: 1, filtering: [{ field: 'campaign.id', operator: 'IN', value: [camp.externalId] }], limit: 1000 });
|
|
336
355
|
const conv = (acts) => (acts || []).filter((a) => ['purchase', 'lead', 'complete_registration', 'offsite_conversion.fb_pixel_purchase', 'offsite_conversion.fb_pixel_lead', 'offsite_conversion.fb_pixel_complete_registration'].includes(a.action_type)).reduce((n, a) => n + Number(a.value), 0);
|
|
337
|
-
return d.data.map((r) => ({ date: r.date_start, level: 'ad', id: r.ad_id, name: r.ad_name, concept: placements.find((p) => p.externalId === r.ad_id)?.concept, format: placements.find((p) => p.externalId === r.ad_id)?.format || 'image', lang: placements.find((p) => p.externalId === r.ad_id)?.lang, spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), videoPlays: Number(r.video_play_actions?.[0]?.value || 0) || undefined, thruplays: Number(r.video_thruplay_watched_actions?.[0]?.value || 0) || undefined, videoP50: Number(r.video_p50_watched_actions?.[0]?.value || 0) || undefined, currency: acc.currency }));
|
|
356
|
+
return d.data.map((r) => ({ date: r.date_start, level: 'ad', id: r.ad_id, name: r.ad_name, adsetId: r.adset_id, concept: placements.find((p) => p.externalId === r.ad_id)?.concept, format: placements.find((p) => p.externalId === r.ad_id)?.format || 'image', lang: placements.find((p) => p.externalId === r.ad_id)?.lang, spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), videoPlays: Number(r.video_play_actions?.[0]?.value || 0) || undefined, thruplays: Number(r.video_thruplay_watched_actions?.[0]?.value || 0) || undefined, videoP50: Number(r.video_p50_watched_actions?.[0]?.value || 0) || undefined, currency: acc.currency }));
|
|
338
357
|
}
|
|
339
358
|
async policy(placements) {
|
|
340
359
|
const out = [];
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export type BuildInput = {
|
|
|
32
32
|
conversionActionRn?: string;
|
|
33
33
|
existing: Placement[];
|
|
34
34
|
validateOnly?: boolean;
|
|
35
|
+
/** 브리프 오디언스(연령대·성별) → 메타 타겟 · strict 면 Advantage+ 를 끄고 상한·성별 강제(meta-targeting.ts) */
|
|
36
|
+
audience?: {
|
|
37
|
+
ageRange?: string;
|
|
38
|
+
gender?: string;
|
|
39
|
+
};
|
|
40
|
+
strictAudience?: boolean;
|
|
35
41
|
log?: (s: string) => void;
|
|
36
42
|
};
|
|
37
43
|
export type BuildResult = {
|
|
@@ -45,7 +51,8 @@ export type MetricRow = {
|
|
|
45
51
|
name: string;
|
|
46
52
|
concept?: string;
|
|
47
53
|
format?: 'image' | 'video';
|
|
48
|
-
lang?: string; /**
|
|
54
|
+
lang?: string; /** 광고세트(비교 묶음) */
|
|
55
|
+
adsetId?: string; /** 영상: 재생 수 · 완주(ThruPlay · 15초 또는 끝까지) · 50% 시청 — 훅률·완주율 계산 */
|
|
49
56
|
videoPlays?: number;
|
|
50
57
|
thruplays?: number;
|
|
51
58
|
videoP50?: number;
|
package/dist/cli.js
CHANGED
|
@@ -104,10 +104,10 @@ program.command('brief <url>').description('사이트 읽고 브리프 만들기
|
|
|
104
104
|
die(e);
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
|
-
program.command('creatives').description('컨셉·카피 + 이미지 생성 + 12규격 합성 + 규격 검사').option('--concepts <n>', '컨셉 수', '3').option('--feedback <text>', '자연어 수정(「더 고급스럽게」) — 전부 다시 만든다').option('--images <folder>', '직접 준비한 배경 이미지 폴더').option('--no-images', '이미지 생성 없이 브랜드 그래디언트').option('--force', '다시 만들기').option('--mode <mode>', '소재 형태 auto|image|video|text').option('--video', '영상 소재도 만든다(9:16 8초 · Veo 3.1 · 소리 포함)').option('--video-seconds <n>', '영상 길이 8|15|30 (15/30 은 Seedance 2.5)').option('--requirements <text>', '요구사항·방향(참고 이미지는 --images 폴더)').action(async (o) => {
|
|
107
|
+
program.command('creatives').description('컨셉·카피 + 이미지 생성 + 12규격 합성 + 규격 검사').option('--concepts <n>', '컨셉 수', '3').option('--feedback <text>', '자연어 수정(「더 고급스럽게」) — 전부 다시 만든다').option('--images <folder>', '직접 준비한 배경 이미지 폴더').option('--videos <folder>', '제품이 실제로 만든 영상 폴더(mp4) — 생성 없이 훅·자막·CTA·엔드카드·몽타주로 광고 제작(비용 0)').option('--no-images', '이미지 생성 없이 브랜드 그래디언트').option('--force', '다시 만들기').option('--mode <mode>', '소재 형태 auto|image|video|text').option('--video', '영상 소재도 만든다(9:16 8초 · Veo 3.1 · 소리 포함)').option('--video-seconds <n>', '영상 길이 8|15|30 (15/30 은 Seedance 2.5)').option('--requirements <text>', '요구사항·방향(참고 이미지는 --images 폴더)').action(async (o) => {
|
|
108
108
|
try {
|
|
109
109
|
const p = resolveProject(program.opts().project);
|
|
110
|
-
const { project, report } = await opCreatives(p, { count: Number(o.concepts), feedback: o.feedback, images: o.images, noImages: o.images === false, force: o.force, mode: o.mode, video: o.video, videoSeconds: o.videoSeconds ? Number(o.videoSeconds) : undefined, requirements: o.requirements, log });
|
|
110
|
+
const { project, report } = await opCreatives(p, { count: Number(o.concepts), feedback: o.feedback, images: o.images, videos: o.videos, noImages: o.images === false, force: o.force, mode: o.mode, video: o.video, videoSeconds: o.videoSeconds ? Number(o.videoSeconds) : undefined, requirements: o.requirements, log });
|
|
111
111
|
log(`\n컨셉 ${project.concepts.length}개 · 소재 ${project.assets.filter((a) => a.concept !== '_logo').length}장`);
|
|
112
112
|
for (const c of project.concepts)
|
|
113
113
|
log(` · ${c.name}(${c.key}) — ${c.headlines[0]}`);
|
|
@@ -29,3 +29,24 @@ export type FactoryInput = {
|
|
|
29
29
|
export declare function renderEndCard(dir: string, brief: Brief, concept: Concept, ratio: VideoRatio, lang: string, backgrounds?: Record<string, string | null>): Promise<string | null>;
|
|
30
30
|
/** 컨셉의 영상 소재 전부(포맷 × 언어 × 비율) — 실패한 포맷은 건너뛰고 로그 · 성공한 건마다 onAsset */
|
|
31
31
|
export declare function produceConceptVideos(o: FactoryInput): Promise<VideoAsset[]>;
|
|
32
|
+
export type FootageInput = {
|
|
33
|
+
dir: string;
|
|
34
|
+
brief: Brief;
|
|
35
|
+
concept: Concept;
|
|
36
|
+
clips: string[];
|
|
37
|
+
langs?: string[];
|
|
38
|
+
goal?: Goal;
|
|
39
|
+
/** 원본 1편당 최대 길이(초 · 기본 20) · 몽타주 컷 길이(기본 4.5) */
|
|
40
|
+
maxSec?: number;
|
|
41
|
+
montageCutSec?: number;
|
|
42
|
+
montage?: boolean;
|
|
43
|
+
captions?: boolean;
|
|
44
|
+
endCard?: boolean;
|
|
45
|
+
log?: FactoryLog;
|
|
46
|
+
onAsset?: (a: VideoAsset) => void | Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
export declare const FOOTAGE_FORMATS: {
|
|
49
|
+
readonly single: "footage";
|
|
50
|
+
readonly montage: "montage";
|
|
51
|
+
};
|
|
52
|
+
export declare function produceFootageVideos(o: FootageInput): Promise<VideoAsset[]>;
|
|
@@ -7,9 +7,9 @@ import path from 'node:path';
|
|
|
7
7
|
import { DERIVED_RATIOS, RETAKE_CAP_RATIO, VIDEO_DIMS, hasSeedance, speechVideoModel } from './media.js';
|
|
8
8
|
import { genreStyle, PACE, SCENE_FORMATS, TONE_WORDS } from './playbook.js';
|
|
9
9
|
import { ctaLabel, renderConcept, renderOverlay, renderPhoneStage } from './render.js';
|
|
10
|
-
import { langName, localizedBoard, localizedConcept, planStoryboards, translateCopy } from './storyboard.js';
|
|
10
|
+
import { fitHook, langName, localizedBoard, localizedConcept, planStoryboards, translateCopy } from './storyboard.js';
|
|
11
11
|
import { generateVideo } from './video.js';
|
|
12
|
-
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, lastFrame, mixVoiceover, motionScore, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
|
|
12
|
+
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, lastFrame, mixVoiceover, motionScore, normalize, pickHookBand, probe, refCanvas, tailLoudnessDb, trimClip, uiDemoClip } from './videofx.js';
|
|
13
13
|
import { synthesizeSpeech, ttsAvailable } from './tts.js';
|
|
14
14
|
import { spokenForm } from './speech.js';
|
|
15
15
|
import { isMockGen } from './images.js';
|
|
@@ -409,3 +409,125 @@ export async function produceConceptVideos(o) {
|
|
|
409
409
|
}
|
|
410
410
|
return out;
|
|
411
411
|
}
|
|
412
|
+
export const FOOTAGE_FORMATS = { single: 'footage', montage: 'montage' };
|
|
413
|
+
export async function produceFootageVideos(o) {
|
|
414
|
+
const log = o.log || (() => { });
|
|
415
|
+
const c = o.concept;
|
|
416
|
+
const out = [];
|
|
417
|
+
const ff = await hasFfmpeg();
|
|
418
|
+
const baseLang = o.brief.language || 'ko';
|
|
419
|
+
const langs = o.langs?.length ? [...new Set(o.langs)] : [baseLang];
|
|
420
|
+
const ratio = '9x16';
|
|
421
|
+
const dims = VIDEO_DIMS[ratio];
|
|
422
|
+
const maxSec = o.maxSec ?? 20;
|
|
423
|
+
const cut = o.montageCutSec ?? 4.5;
|
|
424
|
+
const clips = o.clips.filter((f) => fs.existsSync(f)).slice(0, 4);
|
|
425
|
+
if (!clips.length) {
|
|
426
|
+
log(' ⚠ 실제 산출물 영상이 없어요');
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
if (!ff) {
|
|
430
|
+
log(' ⚠ ffmpeg 가 없어 원본을 그대로 써요');
|
|
431
|
+
for (const [i, f] of clips.entries()) {
|
|
432
|
+
const a = { concept: c.key, w: dims.w, h: dims.h, ratio, file: f, medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw: 0, origin: 'upload', format: FOOTAGE_FORMATS.single, lang: langs[0], variant: `footage_${i}` };
|
|
433
|
+
out.push(a);
|
|
434
|
+
await o.onAsset?.(a);
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
// 시장 언어 번역(문구) — 콘티가 없으므로 헤드라인·본문만 옮긴다(이미 있으면 재사용)
|
|
439
|
+
const extra = langs.filter((l) => l.split('-')[0] !== baseLang.split('-')[0]);
|
|
440
|
+
if (extra.length && (!c.i18n || extra.some((l) => !c.i18n[l]))) {
|
|
441
|
+
try {
|
|
442
|
+
log(` ${extra.map(langName).join('·')} 로 문구 옮기는 중…`);
|
|
443
|
+
c.i18n = { ...(c.i18n || {}), ...(await translateCopy(o.brief, c, c.storyboards || [], extra)) };
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
log(` ⚠ 번역 실패(원문 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// 원본 정규화(9:16 cover · ≤maxSec) — 같은 원본은 컨셉이 달라도 한 번만
|
|
450
|
+
const raws = [];
|
|
451
|
+
for (const [i, f] of clips.entries()) {
|
|
452
|
+
const raw = path.join(o.dir, `footage_${i}_${ratio}.raw.mp4`);
|
|
453
|
+
try {
|
|
454
|
+
if (!fs.existsSync(raw))
|
|
455
|
+
await normalize(f, raw, ratio, maxSec);
|
|
456
|
+
raws.push({ file: raw, idx: i });
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
log(` ⚠ 원본 ${i + 1} 정규화 실패: ${e instanceof Error ? e.message.slice(0, 120) : e}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const pieces = raws.map((r) => ({ key: `${c.key}_footage${r.idx}`, format: FOOTAGE_FORMATS.single, raw: r.file, variant: `footage_${r.idx}` }));
|
|
463
|
+
// 몽타주 — 원본 2편 이상이면 각 t0=1.0 에서 cut 초씩(최대 3) 하드컷
|
|
464
|
+
if ((o.montage ?? true) && raws.length >= 2) {
|
|
465
|
+
try {
|
|
466
|
+
const segs = [];
|
|
467
|
+
for (const r of raws.slice(0, 3)) {
|
|
468
|
+
const s = path.join(o.dir, `montage_seg_${r.idx}.mp4`);
|
|
469
|
+
if (!fs.existsSync(s))
|
|
470
|
+
await trimClip(r.file, s, { t0: 1.0, sec: cut });
|
|
471
|
+
segs.push(s);
|
|
472
|
+
}
|
|
473
|
+
const mont = path.join(o.dir, `${c.key}_montage_${ratio}.raw.mp4`);
|
|
474
|
+
if (!fs.existsSync(mont))
|
|
475
|
+
await concatClips(segs, mont, dims);
|
|
476
|
+
pieces.push({ key: `${c.key}_montage`, format: FOOTAGE_FORMATS.montage, raw: mont, variant: 'montage' });
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
log(` ⚠ 몽타주 실패: ${e instanceof Error ? e.message.slice(0, 120) : e}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
log(`「${c.name}」 실제 산출물 ${raws.length}편${pieces.length > raws.length ? ' + 몽타주 1' : ''} → 훅·자막·CTA·엔드카드(언어 ${langs.join('·')} · 생성 비용 0)`);
|
|
483
|
+
for (const pc of pieces) {
|
|
484
|
+
for (const lang of langs) {
|
|
485
|
+
const lc = localizedConcept(c, c.i18n, lang);
|
|
486
|
+
const cta = ctaLabel(c.cta, lang);
|
|
487
|
+
// 훅 = 헤드라인 1 · 자막 = 영상 자막 줄(브리프 언어) 또는 헤드라인 2~3 · 몽타주는 자막을 끝까지
|
|
488
|
+
const lines = (lang === baseLang && c.videoLines?.length ? c.videoLines : lc.headlines.slice(1, 3)).filter(Boolean);
|
|
489
|
+
const text = { hook: fitHook(lc.headlines[0] || c.headlines[0] || ''), captions: lines.slice(0, 3) };
|
|
490
|
+
const ratios = [ratio, ...DERIVED_RATIOS[ratio]];
|
|
491
|
+
for (const r of ratios) {
|
|
492
|
+
const d = VIDEO_DIMS[r];
|
|
493
|
+
const finalFile = path.join(o.dir, `${pc.key}_${lang}_${r}.mp4`);
|
|
494
|
+
let cur = pc.raw;
|
|
495
|
+
let durationSec = 0;
|
|
496
|
+
try {
|
|
497
|
+
const pr0 = await probe(pc.raw);
|
|
498
|
+
durationSec = pr0.durationSec;
|
|
499
|
+
if (r !== ratio) {
|
|
500
|
+
const df = path.join(o.dir, `${pc.key}_${r}.raw.mp4`);
|
|
501
|
+
if (!fs.existsSync(df))
|
|
502
|
+
await deriveRatio(pc.raw, df, r);
|
|
503
|
+
cur = df;
|
|
504
|
+
}
|
|
505
|
+
if (o.captions ?? true) {
|
|
506
|
+
const layout = await pickHookBand(cur);
|
|
507
|
+
const withCap = path.join(o.dir, `${pc.key}_${lang}_${r}.cap.mp4`);
|
|
508
|
+
await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, pc.key, d, text, cta, o.brief.palette.primary, durationSec, lang, layout) });
|
|
509
|
+
cur = withCap;
|
|
510
|
+
}
|
|
511
|
+
if (o.endCard ?? true) {
|
|
512
|
+
const card = await renderEndCard(o.dir, o.brief, lc, r, lang, {});
|
|
513
|
+
if (card) {
|
|
514
|
+
const withEnd = path.join(o.dir, `${pc.key}_${lang}_${r}.end.mp4`);
|
|
515
|
+
await appendEndCard(cur, card, withEnd, 2);
|
|
516
|
+
cur = withEnd;
|
|
517
|
+
durationSec += 2;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
log(` ⚠ ${pc.variant} ${lang} ${r} 마감 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 160) : e}`);
|
|
523
|
+
}
|
|
524
|
+
fs.copyFileSync(cur, finalFile);
|
|
525
|
+
const asset = { concept: c.key, w: d.w, h: d.h, ratio: r, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: 0, origin: 'upload', format: pc.format, lang, variant: `${pc.variant}_${lang}` };
|
|
526
|
+
out.push(asset);
|
|
527
|
+
await o.onAsset?.(asset);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
log(` 실제 산출물 광고 ${out.length}편 완성(원본 ${raws.length} × 언어 ${langs.length} × 비율 3${pieces.length > raws.length ? ' + 몽타주' : ''}) · 생성 원가 ₩0`);
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Medium } from '../../adapters/types.js';
|
|
2
2
|
import { type Tone, type VideoFormat } from './playbook.js';
|
|
3
3
|
export type CreativeMode = 'auto' | 'image' | 'video' | 'text';
|
|
4
|
-
|
|
4
|
+
/** product = 제품이 실제로 만든 결과물(사용자 산출물·데모 영상 URL)을 편집 구조로 감싸 광고로(생성 비용 0 · 밈튜브 하네스 학습 2026-09-23) */
|
|
5
|
+
export type CreativeSource = 'ai' | 'guided' | 'manual' | 'product';
|
|
5
6
|
export type Tier = 'economy' | 'standard' | 'premium';
|
|
6
7
|
export type VideoRatio = '9x16' | '16x9' | '1x1' | '4x5';
|
|
7
8
|
export type VideoProvider = 'auto' | 'veo' | 'seedance';
|
|
@@ -37,6 +38,10 @@ export type CreativeSpec = {
|
|
|
37
38
|
bodies?: string[];
|
|
38
39
|
descriptions?: string[];
|
|
39
40
|
};
|
|
41
|
+
/** product: 실제 산출물 영상 URL(≤10 · mp4/mov/webm) — 내려받아 훅·자막·CTA·엔드카드·몽타주로 감싼다 */
|
|
42
|
+
footageUrls?: string[];
|
|
43
|
+
/** 메타 타겟 — auto(기본 · Advantage+ 안에서 최소 연령·언어만) · strict(브리프 연령대 상한·성별 강제 · Advantage+ 끔) */
|
|
44
|
+
audience?: 'auto' | 'strict';
|
|
40
45
|
};
|
|
41
46
|
export type VideoJob = {
|
|
42
47
|
ratio: VideoRatio;
|
|
@@ -31,7 +31,7 @@ export function pickVideoModel(durationSec, tier, provider = 'auto', ratio = '9x
|
|
|
31
31
|
}
|
|
32
32
|
const FORMAT_LABEL_SHORT = { ugc_selfie: '셀카 후기', ui_demo: '화면 데모', cinematic: '시네마틱', product_hero: '제품', lifestyle: '라이프스타일', text_hook: '글자 훅' };
|
|
33
33
|
export const MODE_LABEL = { image: '이미지 + 문구(기본)', auto: '이미지 + 영상(베타)', video: '영상 위주(베타)', text: '글·링크 위주' };
|
|
34
|
-
export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구사항·참고 이미지로', manual: '직접 올린 소재로' };
|
|
34
|
+
export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구사항·참고 이미지로', manual: '직접 올린 소재로', product: '실제 산출물 영상으로(URL)' };
|
|
35
35
|
/**
|
|
36
36
|
* 설정 + 예산 → 실행 계획. 온라인 광고 소재의 일반 배합:
|
|
37
37
|
* - 이미지는 컨셉마다 비율 4종(1:1·4:5·9:16·16:9) 배경을 만들어 12규격으로 합성(기존) · 「이미지 위주」는 배경 2세트(A/B)
|
|
@@ -49,14 +49,15 @@ export function resolveCreativePlan(spec, ctx) {
|
|
|
49
49
|
const mode = s.mode || 'image';
|
|
50
50
|
const source = s.source || 'ai';
|
|
51
51
|
const small = ctx.monthlyKrw < 500_000;
|
|
52
|
-
const concepts = Math.max(1, Math.min(5, s.concepts || (source === 'manual' ? 1 : mode === 'text' ? 4 : 3)));
|
|
52
|
+
const concepts = Math.max(1, Math.min(5, s.concepts || (source === 'manual' ? 1 : source === 'product' ? 2 : mode === 'text' ? 4 : 3)));
|
|
53
53
|
const imgTier = s.images?.tier || (ctx.monthlyKrw >= 3_000_000 ? 'standard' : 'economy');
|
|
54
|
-
|
|
54
|
+
// product: 이미지 배경은 실제 산출물의 프레임(생성 0) · 영상은 생성하지 않고 산출물을 감싼다
|
|
55
|
+
const imagesEnabled = source !== 'manual' && source !== 'product' && mode !== 'text';
|
|
55
56
|
const variants = Math.max(1, Math.min(3, s.images?.variants || (mode === 'image' ? 2 : 1)));
|
|
56
57
|
const ratios = ['1x1', '4x5', '9x16', '16x9'];
|
|
57
58
|
const vTier = s.video?.tier || (ctx.monthlyKrw >= 2_000_000 ? 'standard' : 'economy');
|
|
58
59
|
const duration = s.video?.durationSec || 8;
|
|
59
|
-
const wantVideo = source !== 'manual' && s.video?.count !== 0 && mode !== 'text' && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0 || (s.video?.formats?.length || 0) > 0);
|
|
60
|
+
const wantVideo = source !== 'manual' && source !== 'product' && s.video?.count !== 0 && mode !== 'text' && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0 || (s.video?.formats?.length || 0) > 0);
|
|
60
61
|
const vRatios = s.video?.ratios?.length ? s.video.ratios : ['9x16'];
|
|
61
62
|
const pb = playbookFor(ctx.industry);
|
|
62
63
|
const tone = s.tone || pb.tone;
|
|
@@ -91,6 +92,8 @@ export function resolveCreativePlan(spec, ctx) {
|
|
|
91
92
|
lines.push(`이미지 배경 ${concepts * variants * ratios.length}장(방향 ${concepts} × 세트 ${variants} × 비율 4) ≈ ₩${imagesKrw.toLocaleString()} → 12가지 크기로 합성(합성은 무료)`);
|
|
92
93
|
else if (source === 'manual')
|
|
93
94
|
lines.push('이미지·영상은 올려 주신 것을 그대로 써요(생성 비용 0)');
|
|
95
|
+
else if (source === 'product')
|
|
96
|
+
lines.push(`실제 산출물 영상 ${s.footageUrls?.length || 0}편을 방향 ${concepts}가지 훅·자막·CTA·엔드카드로 감싸고${(s.footageUrls?.length || 0) >= 2 ? ' 몽타주 1편을 더해' : ''} 광고로 만들어요 · 이미지는 그 영상의 장면으로(생성 비용 0)`);
|
|
94
97
|
else
|
|
95
98
|
lines.push('이미지 생성 없이 브랜드 색 배경 + 문구로 만들어요(비용 0)');
|
|
96
99
|
if (conceptsWithVideo)
|
|
@@ -86,3 +86,8 @@ export declare function refCanvas(ref: string, bgPng: string, output: string, di
|
|
|
86
86
|
export declare function motionScore(video: string): Promise<number>;
|
|
87
87
|
/** 마지막 프레임 PNG — 다음 컷 첫 프레임(인물·장소 연속) */
|
|
88
88
|
export declare function lastFrame(video: string, output: string): Promise<string>;
|
|
89
|
+
/** 구간 잘라내기(재인코딩 · 몽타주용) — t0 에서 sec 초 · 원본이 짧으면 있는 만큼 */
|
|
90
|
+
export declare function trimClip(input: string, output: string, o: {
|
|
91
|
+
t0: number;
|
|
92
|
+
sec: number;
|
|
93
|
+
}): Promise<void>;
|
|
@@ -239,3 +239,9 @@ export async function lastFrame(video, output) {
|
|
|
239
239
|
await execFileP('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.15', '-i', video, '-frames:v', '1', '-update', '1', output]);
|
|
240
240
|
return output;
|
|
241
241
|
}
|
|
242
|
+
/** 구간 잘라내기(재인코딩 · 몽타주용) — t0 에서 sec 초 · 원본이 짧으면 있는 만큼 */
|
|
243
|
+
export async function trimClip(input, output, o) {
|
|
244
|
+
const p = await probe(input);
|
|
245
|
+
const t0 = Math.max(0, Math.min(o.t0, Math.max(0, p.durationSec - o.sec)));
|
|
246
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', String(t0), '-i', input, '-t', String(Math.min(o.sec, Math.max(0.5, p.durationSec - t0))), ...(p.hasAudio ? [] : ['-f', 'lavfi', '-t', String(o.sec), '-i', 'anullsrc=r=48000:cl=stereo']), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', ...(p.hasAudio ? [] : ['-shortest']), '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
|
|
247
|
+
}
|
package/dist/core/ops.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { connector } from '../adapters/index.js';
|
|
|
2
2
|
import type { CheckItem, Medium, MetricRow } from '../adapters/types.js';
|
|
3
3
|
import { type CreativeMode, type CreativeSpec } from './creatives/media.js';
|
|
4
4
|
import { type Proposal } from './optimize.js';
|
|
5
|
-
import { type Goal, type Market, type Project } from './state.js';
|
|
5
|
+
import { type Goal, type Market, type Placement, type Project } from './state.js';
|
|
6
6
|
export type Log = (s: string) => void;
|
|
7
7
|
export declare function parseMarkets(s?: string, siteLang?: string): Market[];
|
|
8
8
|
export declare function opBrief(url: string, o: {
|
|
@@ -19,7 +19,8 @@ export declare function opBrief(url: string, o: {
|
|
|
19
19
|
export declare function opCreatives(p: Project, o: {
|
|
20
20
|
count?: number;
|
|
21
21
|
feedback?: string;
|
|
22
|
-
images?: string;
|
|
22
|
+
images?: string; /** 실제 산출물 영상 폴더(mp4·mov·webm) → source=product · 생성 없이 훅·자막·CTA·엔드카드·몽타주 */
|
|
23
|
+
videos?: string;
|
|
23
24
|
noImages?: boolean;
|
|
24
25
|
force?: boolean;
|
|
25
26
|
mode?: CreativeMode;
|
|
@@ -46,6 +47,7 @@ export declare function opBuild(p: Project, o?: {
|
|
|
46
47
|
warnings: string[];
|
|
47
48
|
errors: string[];
|
|
48
49
|
}>;
|
|
50
|
+
export declare function mergePlacements(a: Placement[], b: Placement[]): Placement[];
|
|
49
51
|
export declare function opPreview(p: Project, o?: {
|
|
50
52
|
log?: Log;
|
|
51
53
|
}): Promise<{
|
package/dist/core/ops.js
CHANGED
|
@@ -6,7 +6,8 @@ import { buildBrief, explainBrief } from './brief.js';
|
|
|
6
6
|
import { generateConcepts, validateConcepts } from './creatives/copy.js';
|
|
7
7
|
import { generateBackgrounds, userBackgrounds } from './creatives/images.js';
|
|
8
8
|
import { chargeFor, resolveCreativePlan } from './creatives/media.js';
|
|
9
|
-
import { produceConceptVideos } from './creatives/factory.js';
|
|
9
|
+
import { produceConceptVideos, produceFootageVideos } from './creatives/factory.js';
|
|
10
|
+
import { thumbnail } from './creatives/videofx.js';
|
|
10
11
|
import { downloadSiteImages, screenshotSite } from './creatives/siteshots.js';
|
|
11
12
|
import { filterProductRefs } from './creatives/qa.js';
|
|
12
13
|
import { renderConcept, renderLogo } from './creatives/render.js';
|
|
@@ -55,7 +56,10 @@ export async function opCreatives(p, o) {
|
|
|
55
56
|
if (p.brief.specialCategory)
|
|
56
57
|
throw new Error(`「${p.brief.specialCategory}」 특별 광고 카테고리는 접수할 수 없어요.`);
|
|
57
58
|
// 소재 설정(형태·출처·영상) — 명시 spec > 플래그 > 프로젝트 저장값. CLI 는 --video 없으면 이미지만(비용 예측 가능).
|
|
58
|
-
|
|
59
|
+
// 실제 산출물 폴더(--videos) · 없으면 저장된 product 스펙의 파일을 재사용 · --mode/--video 로 AI 생성을 다시 요청하면 product 를 푼다
|
|
60
|
+
const stored = !o.videos && (o.spec?.source || p.creative?.source) === 'product' ? ((o.spec?.footageUrls || p.creative?.footageUrls || []).filter((f) => fs.existsSync(f))) : [];
|
|
61
|
+
const footage = o.videos ? fs.readdirSync(o.videos).filter((f) => /\.(mp4|mov|webm|m4v)$/i.test(f)).sort().map((f) => path.join(o.videos, f)) : (o.mode || o.video) ? [] : stored;
|
|
62
|
+
const spec = { ...(p.creative || {}), ...(o.spec || {}), ...(o.mode ? { mode: o.mode } : {}), ...(o.requirements ? { source: 'guided', prompt: o.requirements } : {}), ...(footage.length ? { source: 'product', footageUrls: footage } : (p.creative?.source === 'product' || o.spec?.source === 'product') ? { source: 'ai', footageUrls: undefined } : {}) };
|
|
59
63
|
if (o.video === false || (o.video === undefined && !o.spec?.video && !p.creative?.video && !o.mode))
|
|
60
64
|
spec.video = { ...(spec.video || {}), count: 0 };
|
|
61
65
|
if (o.video && !(spec.video?.count))
|
|
@@ -101,6 +105,16 @@ export async function opCreatives(p, o) {
|
|
|
101
105
|
let bgs;
|
|
102
106
|
if (o.images && spec.source !== 'guided')
|
|
103
107
|
bgs = userBackgrounds(o.images, c.key);
|
|
108
|
+
else if (footage.length) {
|
|
109
|
+
const fr = path.join(dir, `footage_frame_${ci}.jpg`);
|
|
110
|
+
try {
|
|
111
|
+
await thumbnail(footage[ci % footage.length], fr, 1.0);
|
|
112
|
+
bgs = Object.fromEntries(['1x1', '4x5', '9x16', '16x9'].map((r) => [r, fr]));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
bgs = {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
104
118
|
else if (o.noImages || !cplan.images.enabled)
|
|
105
119
|
bgs = {};
|
|
106
120
|
else {
|
|
@@ -109,6 +123,10 @@ export async function opCreatives(p, o) {
|
|
|
109
123
|
}
|
|
110
124
|
log(`합성 ${c.key} × ${SIZES.length}규격…`);
|
|
111
125
|
assets.push(...(await renderConcept(dir, p.brief, c, bgs, { force: o.force || !!o.feedback, log })));
|
|
126
|
+
if (footage.length) {
|
|
127
|
+
const vids = await produceFootageVideos({ dir, brief: p.brief, concept: c, clips: footage, langs, goal: p.budget.goal, log });
|
|
128
|
+
assets.push(...vids.filter((v) => v.file));
|
|
129
|
+
}
|
|
112
130
|
if (cplan.videos.perConcept.length && ci < cplan.videos.conceptsWithVideo) {
|
|
113
131
|
const vids = await produceConceptVideos({ dir, brief: p.brief, concept: c, jobs: cplan.videos.perConcept, backgrounds: bgs, refs: refs.length ? refs : siteImages, siteShot, siteImages, langs, tone: cplan.tone, goal: p.budget.goal, log, onAsset: (a) => { costKrw += a.costKrw; } });
|
|
114
132
|
assets.push(...vids.filter((v) => v.file));
|
|
@@ -164,7 +182,7 @@ export async function opBuild(p, o = {}) {
|
|
|
164
182
|
saveProject(p);
|
|
165
183
|
log(` google 전환 액션 ${ca.gtagId}/${ca.label}`);
|
|
166
184
|
}
|
|
167
|
-
const r = await c.build({ slug: p.slug, brief: p.brief, concepts, assets: p.assets, goal: p.budget.goal, markets: plan.markets, dailyBudgetMinor: pm.dailyBudgetMinor, currency: pm.currency, endDate: plan.endDate, landingUrl: p.url, utmCampaign: `adpilot_${p.slug}`, pixelId: p.tracking?.pixelId, conversionActionRn, existing: p.placements, validateOnly: o.validate, log });
|
|
185
|
+
const r = await c.build({ slug: p.slug, brief: p.brief, concepts, assets: p.assets, goal: p.budget.goal, markets: plan.markets, dailyBudgetMinor: pm.dailyBudgetMinor, currency: pm.currency, endDate: plan.endDate, landingUrl: p.url, utmCampaign: `adpilot_${p.slug}`, pixelId: p.tracking?.pixelId, conversionActionRn, existing: p.placements, validateOnly: o.validate, audience: p.brief.audienceProfile ? { ageRange: p.brief.audienceProfile.ageRange, gender: p.brief.audienceProfile.gender } : undefined, strictAudience: p.creative?.audience === 'strict', log });
|
|
168
186
|
warnings.push(...r.warnings);
|
|
169
187
|
if (!o.validate) {
|
|
170
188
|
p.placements = mergePlacements(p.placements, r.placements);
|
|
@@ -186,8 +204,10 @@ export async function opBuild(p, o = {}) {
|
|
|
186
204
|
}
|
|
187
205
|
return { project: p, warnings, errors };
|
|
188
206
|
}
|
|
189
|
-
|
|
190
|
-
|
|
207
|
+
// 키에 format·lang 포함 — 같은 컨셉의 이미지/영상·언어별 광고세트가 서로 덮이지 않게(웹 pipeline 과 같은 키 · 2026-09-23)
|
|
208
|
+
const placementKey = (x) => `${x.medium}/${x.kind}/${x.concept || ''}/${x.format || 'image'}/${x.lang || ''}`;
|
|
209
|
+
export function mergePlacements(a, b) { const m = new Map(a.map((x) => [placementKey(x), x])); for (const x of b)
|
|
210
|
+
m.set(placementKey(x), x); return [...m.values()]; }
|
|
191
211
|
export async function opPreview(p, o = {}) {
|
|
192
212
|
const items = [];
|
|
193
213
|
for (const m of [...new Set(p.placements.map((x) => x.medium))]) {
|
package/dist/core/optimize.d.ts
CHANGED
|
@@ -5,23 +5,69 @@ export type Proposal = {
|
|
|
5
5
|
kind: 'pause_ad' | 'budget_up' | 'budget_down' | 'note';
|
|
6
6
|
target?: string;
|
|
7
7
|
concept?: string;
|
|
8
|
+
group?: string;
|
|
8
9
|
reason: string;
|
|
9
10
|
newDailyMinor?: number;
|
|
10
11
|
};
|
|
12
|
+
export type OptimizeOpts = {
|
|
13
|
+
/** 이미 꺼진 광고(반복해서 끄지 않게 · 비교에서도 제외) */
|
|
14
|
+
excludeIds?: string[];
|
|
15
|
+
/** 마지막 예산 변경 시각 — 쿨다운(기본 24시간) 안이면 예산 제안을 내지 않는다 */
|
|
16
|
+
lastBudgetChangeAt?: string;
|
|
17
|
+
cooldownHours?: number;
|
|
18
|
+
/** 매체 최소 일 예산(최소 단위) — budget_down 이 이 아래로 내려가지 않는다 */
|
|
19
|
+
floorMinor?: number;
|
|
20
|
+
/** 비교 묶음 키(기본 lang · 없으면 전체) */
|
|
21
|
+
groupBy?: 'lang' | 'adset' | 'none';
|
|
22
|
+
};
|
|
23
|
+
export declare const LEARNING_HOURS = 48;
|
|
24
|
+
export declare const MIN_IMPRESSIONS = 2000;
|
|
25
|
+
export declare const PAUSE_RATIO = 1.8;
|
|
26
|
+
export declare const BUDGET_COOLDOWN_HOURS = 24;
|
|
27
|
+
/** 영상 보호 — 훅률·완주율이 이 이상이면 클릭률만으로 끄지 않는다 */
|
|
28
|
+
export declare const VIDEO_KEEP: {
|
|
29
|
+
hookRate: number;
|
|
30
|
+
completion: number;
|
|
31
|
+
};
|
|
11
32
|
export declare function summarize(rows: MetricRow[]): {
|
|
12
33
|
days: number;
|
|
13
34
|
ctr: number;
|
|
14
35
|
cpc: number;
|
|
15
36
|
cpa: number;
|
|
37
|
+
hookRate: number;
|
|
38
|
+
completion: number;
|
|
16
39
|
id: string;
|
|
17
40
|
name: string;
|
|
18
41
|
concept?: string;
|
|
42
|
+
format?: "image" | "video";
|
|
43
|
+
lang?: string;
|
|
44
|
+
adsetId?: string;
|
|
19
45
|
spend: number;
|
|
20
46
|
impressions: number;
|
|
21
47
|
clicks: number;
|
|
22
48
|
conversions: number;
|
|
49
|
+
videoPlays: number;
|
|
50
|
+
thruplays: number;
|
|
23
51
|
currency: string;
|
|
24
52
|
}[];
|
|
25
|
-
export
|
|
53
|
+
export type AdSummary = ReturnType<typeof summarize>[number];
|
|
54
|
+
export declare function proposeFor(medium: 'meta' | 'google', rows: MetricRow[], plan: Plan, launchedAt?: string, opts?: OptimizeOpts): Proposal[];
|
|
26
55
|
/** 예산 재배분이 월 상한을 넘지 않는지 — 매체별 새 일 예산(원 환산) 합 ≤ 계획 일 예산 */
|
|
27
56
|
export declare function withinCap(plan: Plan, newDailyKrwByMedium: Record<string, number>): boolean;
|
|
57
|
+
/** 승자·패자 — 새로고침(승자 유지 · 패자만 교체)에 넘길 컨셉 키. 노출 기준을 넘은 광고만 · 컨셉 단위로 합산 */
|
|
58
|
+
export declare function winnersAndLosers(rows: MetricRow[], o?: {
|
|
59
|
+
minImpressions?: number;
|
|
60
|
+
top?: number;
|
|
61
|
+
}): {
|
|
62
|
+
winners: string[];
|
|
63
|
+
losers: string[];
|
|
64
|
+
table: {
|
|
65
|
+
concept: string;
|
|
66
|
+
impressions: number;
|
|
67
|
+
clicks: number;
|
|
68
|
+
ctr: number;
|
|
69
|
+
conversions: number;
|
|
70
|
+
hookRate: number;
|
|
71
|
+
completion: number;
|
|
72
|
+
}[];
|
|
73
|
+
};
|
package/dist/core/optimize.js
CHANGED
|
@@ -1,52 +1,94 @@
|
|
|
1
|
+
export const LEARNING_HOURS = 48;
|
|
2
|
+
export const MIN_IMPRESSIONS = 2000;
|
|
3
|
+
export const PAUSE_RATIO = 1.8;
|
|
4
|
+
export const BUDGET_COOLDOWN_HOURS = 24;
|
|
5
|
+
/** 영상 보호 — 훅률·완주율이 이 이상이면 클릭률만으로 끄지 않는다 */
|
|
6
|
+
export const VIDEO_KEEP = { hookRate: 0.25, completion: 0.35 };
|
|
1
7
|
export function summarize(rows) {
|
|
2
8
|
const by = new Map();
|
|
3
9
|
for (const r of rows) {
|
|
4
10
|
const k = r.id;
|
|
5
|
-
const s = by.get(k) || { id: r.id, name: r.name, concept: r.concept, spend: 0, impressions: 0, clicks: 0, conversions: 0, currency: r.currency, days: new Set() };
|
|
11
|
+
const s = by.get(k) || { id: r.id, name: r.name, concept: r.concept, format: r.format, lang: r.lang, adsetId: r.adsetId, spend: 0, impressions: 0, clicks: 0, conversions: 0, videoPlays: 0, thruplays: 0, currency: r.currency, days: new Set() };
|
|
6
12
|
s.spend += r.spend;
|
|
7
13
|
s.impressions += r.impressions;
|
|
8
14
|
s.clicks += r.clicks;
|
|
9
15
|
s.conversions += r.conversions;
|
|
16
|
+
s.videoPlays += r.videoPlays || 0;
|
|
17
|
+
s.thruplays += r.thruplays || 0;
|
|
10
18
|
if (r.date)
|
|
11
19
|
s.days.add(r.date);
|
|
12
20
|
by.set(k, s);
|
|
13
21
|
}
|
|
14
|
-
return [...by.values()].map((s) => ({ ...s, days: s.days.size, ctr: s.impressions ? s.clicks / s.impressions : 0, cpc: s.clicks ? s.spend / s.clicks : 0, cpa: s.conversions ? s.spend / s.conversions : 0 }));
|
|
22
|
+
return [...by.values()].map((s) => ({ ...s, days: s.days.size, ctr: s.impressions ? s.clicks / s.impressions : 0, cpc: s.clicks ? s.spend / s.clicks : 0, cpa: s.conversions ? s.spend / s.conversions : 0, hookRate: s.impressions ? s.videoPlays / s.impressions : 0, completion: s.videoPlays ? s.thruplays / s.videoPlays : 0 }));
|
|
15
23
|
}
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
const pct = (n) => `${(n * 100).toFixed(n < 0.1 ? 2 : 1)}%`;
|
|
25
|
+
export function proposeFor(medium, rows, plan, launchedAt, opts = {}) {
|
|
26
|
+
const excluded = new Set(opts.excludeIds || []);
|
|
27
|
+
const ads = summarize(rows).filter((a) => !excluded.has(a.id));
|
|
18
28
|
const out = [];
|
|
19
29
|
const hours = launchedAt ? (Date.now() - new Date(launchedAt).getTime()) / 3600_000 : 0;
|
|
20
30
|
const pm = plan.media.find((m) => m.medium === medium);
|
|
21
31
|
if (!ads.length)
|
|
22
32
|
return [{ medium, kind: 'note', reason: '아직 지표가 없어요(검수 중이거나 노출 전).' }];
|
|
23
|
-
if (hours <
|
|
24
|
-
return [{ medium, kind: 'note', reason: `광고 시작 ${Math.floor(hours)}시간 뒤 — 학습 단계(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
if (hours < LEARNING_HOURS)
|
|
34
|
+
return [{ medium, kind: 'note', reason: `광고 시작 ${Math.floor(hours)}시간 뒤 — 학습 단계(${LEARNING_HOURS}시간)라 소재를 건드리지 않아요.` }];
|
|
35
|
+
// 비교 묶음 — 언어 그룹(광고세트 묶음). 묶음이 하나면 전체 비교와 같다.
|
|
36
|
+
const groupKey = (a) => (opts.groupBy === 'none' ? '' : opts.groupBy === 'adset' ? a.adsetId || '' : a.lang || '');
|
|
37
|
+
const groups = new Map();
|
|
38
|
+
for (const a of ads)
|
|
39
|
+
groups.set(groupKey(a), [...(groups.get(groupKey(a)) || []), a]);
|
|
40
|
+
let anyEligible = false;
|
|
41
|
+
let bestOverall;
|
|
42
|
+
for (const [g, list] of groups) {
|
|
43
|
+
const gl = g ? `[${g}] ` : '';
|
|
44
|
+
const eligible = list.filter((a) => a.impressions >= MIN_IMPRESSIONS);
|
|
45
|
+
if (eligible.length < 2) {
|
|
46
|
+
out.push({ medium, kind: 'note', group: g || undefined, reason: `${gl}노출 ${MIN_IMPRESSIONS.toLocaleString()} 이상인 소재가 2개 미만 — 비교하기엔 일러요.` });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
anyEligible = true;
|
|
50
|
+
const useCpa = eligible.filter((a) => a.conversions > 0).length >= 2 && eligible.reduce((n, a) => n + a.conversions, 0) >= 20;
|
|
51
|
+
const score = (a) => (useCpa ? (a.conversions ? -a.cpa : -Infinity) : a.ctr);
|
|
52
|
+
const sorted = [...eligible].sort((a, b) => score(b) - score(a));
|
|
53
|
+
const best = sorted[0];
|
|
54
|
+
const worst = sorted[sorted.length - 1];
|
|
55
|
+
if (!bestOverall || score(best) > score(bestOverall))
|
|
56
|
+
bestOverall = best;
|
|
57
|
+
const ratio = useCpa ? (best.cpa && worst.cpa ? worst.cpa / best.cpa : 1) : best.ctr && worst.ctr ? best.ctr / worst.ctr : 1;
|
|
58
|
+
const vid = worst.format === 'video' && worst.videoPlays > 0 ? ` · 3초 시청 ${pct(worst.hookRate)} · 완주 ${pct(worst.completion)}` : '';
|
|
59
|
+
if (ratio >= PAUSE_RATIO) {
|
|
60
|
+
// 영상 보호 — 훅률·완주율이 좋은 영상은 인지 단계 소재로 남긴다(클릭률만으로 끄지 않음)
|
|
61
|
+
if (worst.format === 'video' && worst.videoPlays > 0 && worst.hookRate >= VIDEO_KEEP.hookRate && worst.completion >= VIDEO_KEEP.completion)
|
|
62
|
+
out.push({ medium, kind: 'note', group: g || undefined, concept: worst.concept, reason: `${gl}「${worst.concept || worst.name}」 클릭률은 1등의 1/${ratio.toFixed(1)}이지만 3초 시청 ${pct(worst.hookRate)} · 완주 ${pct(worst.completion)}로 잘 보고 있어요 — 끄지 않고 둡니다` });
|
|
63
|
+
else
|
|
64
|
+
out.push({ medium, kind: 'pause_ad', target: worst.id, concept: worst.concept, group: g || undefined, reason: useCpa ? `${gl}「${worst.concept || worst.name}」 전환 단가가 1등의 ${ratio.toFixed(1)}배 — 끄고 예산을 잘 되는 쪽에 몰아요` : `${gl}「${worst.concept || worst.name}」 클릭률 ${pct(worst.ctr)}가 1등(「${best.concept || best.name}」 ${pct(best.ctr)})의 1/${ratio.toFixed(1)}${vid} — 끄고 예산을 잘 되는 쪽에 몰아요` });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// 예산: 전체 평균 CTR이 1% 이상이고 예산 소진율이 높으면 +20% (상한 안에서) · 소진율 낮으면 -20%(최소 예산 위에서) · 24시간 쿨다운
|
|
37
68
|
if (pm) {
|
|
69
|
+
const sinceChange = opts.lastBudgetChangeAt ? (Date.now() - new Date(opts.lastBudgetChangeAt).getTime()) / 3600_000 : Infinity;
|
|
70
|
+
const cooldown = opts.cooldownHours ?? BUDGET_COOLDOWN_HOURS;
|
|
38
71
|
const days = Math.max(1, ...ads.map((a) => a.days));
|
|
39
72
|
const spendPerDay = ads.reduce((n, a) => n + a.spend, 0) / days;
|
|
40
73
|
const planMajor = pm.dailyBudgetMinor / (pm.currency === 'KRW' || pm.currency === 'JPY' ? 1 : 100);
|
|
41
74
|
const pacing = planMajor ? spendPerDay / planMajor : 0;
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
75
|
+
const eligibleAll = ads.filter((a) => a.impressions >= MIN_IMPRESSIONS);
|
|
76
|
+
const avgCtr = eligibleAll.reduce((n, a) => n + a.clicks, 0) / Math.max(1, eligibleAll.reduce((n, a) => n + a.impressions, 0));
|
|
77
|
+
if (sinceChange < cooldown)
|
|
78
|
+
out.push({ medium, kind: 'note', reason: `예산은 ${Math.floor(sinceChange)}시간 전에 바꿨어요 — ${cooldown}시간 지나고 다시 봐요(소진율 ${Math.round(pacing * 100)}%)` });
|
|
79
|
+
else if (pacing > 0.9 && avgCtr >= 0.01)
|
|
80
|
+
out.push({ medium, kind: 'budget_up', reason: `예산을 ${Math.round(pacing * 100)}% 소진하고 클릭률 ${pct(avgCtr)} — 이 매체에 +20% 제안(월 상한 안에서)`, newDailyMinor: Math.floor(pm.dailyBudgetMinor * 1.2) });
|
|
81
|
+
else if (pacing < 0.4) {
|
|
82
|
+
const floor = opts.floorMinor || 0;
|
|
83
|
+
const next = Math.max(floor, Math.floor(pm.dailyBudgetMinor * 0.8));
|
|
84
|
+
if (next < pm.dailyBudgetMinor)
|
|
85
|
+
out.push({ medium, kind: 'budget_down', reason: `예산의 ${Math.round(pacing * 100)}%만 쓰고 있어요 — 노출 경쟁이 약하거나 타겟이 좁아요. -20%${next === floor ? '(매체 최소 예산까지)' : ''}`, newDailyMinor: next });
|
|
86
|
+
else
|
|
87
|
+
out.push({ medium, kind: 'note', reason: `예산의 ${Math.round(pacing * 100)}%만 쓰지만 이미 매체 최소 예산이에요 — 그대로 둡니다` });
|
|
88
|
+
}
|
|
47
89
|
}
|
|
48
|
-
if (!out.
|
|
49
|
-
out.push({ medium, kind: 'note', reason: `소재 간 차이가 크지 않아 그대로 둡니다 (1등 「${
|
|
90
|
+
if (!out.some((o) => o.kind !== 'note') && anyEligible && bestOverall)
|
|
91
|
+
out.push({ medium, kind: 'note', reason: `소재 간 차이가 크지 않아 그대로 둡니다 (1등 「${bestOverall.concept || bestOverall.name}」).` });
|
|
50
92
|
return out;
|
|
51
93
|
}
|
|
52
94
|
/** 예산 재배분이 월 상한을 넘지 않는지 — 매체별 새 일 예산(원 환산) 합 ≤ 계획 일 예산 */
|
|
@@ -54,3 +96,25 @@ export function withinCap(plan, newDailyKrwByMedium) {
|
|
|
54
96
|
const total = plan.media.reduce((n, m) => n + (newDailyKrwByMedium[m.medium] ?? m.dailyBudgetKrw), 0);
|
|
55
97
|
return total <= plan.dailyKrw * 1.02 + 2000;
|
|
56
98
|
}
|
|
99
|
+
/** 승자·패자 — 새로고침(승자 유지 · 패자만 교체)에 넘길 컨셉 키. 노출 기준을 넘은 광고만 · 컨셉 단위로 합산 */
|
|
100
|
+
export function winnersAndLosers(rows, o = {}) {
|
|
101
|
+
const byConcept = new Map();
|
|
102
|
+
for (const r of rows) {
|
|
103
|
+
const k = (r.concept || '').replace(/\(영상\)$/, '');
|
|
104
|
+
if (!k)
|
|
105
|
+
continue;
|
|
106
|
+
const s = byConcept.get(k) || { concept: k, impressions: 0, clicks: 0, conversions: 0, videoPlays: 0, thruplays: 0 };
|
|
107
|
+
s.impressions += r.impressions;
|
|
108
|
+
s.clicks += r.clicks;
|
|
109
|
+
s.conversions += r.conversions;
|
|
110
|
+
s.videoPlays += r.videoPlays || 0;
|
|
111
|
+
s.thruplays += r.thruplays || 0;
|
|
112
|
+
byConcept.set(k, s);
|
|
113
|
+
}
|
|
114
|
+
const table = [...byConcept.values()].map((s) => ({ concept: s.concept, impressions: s.impressions, clicks: s.clicks, conversions: s.conversions, ctr: s.impressions ? s.clicks / s.impressions : 0, hookRate: s.impressions ? s.videoPlays / s.impressions : 0, completion: s.videoPlays ? s.thruplays / s.videoPlays : 0 })).sort((a, b) => b.ctr - a.ctr);
|
|
115
|
+
const eligible = table.filter((t) => t.impressions >= (o.minImpressions ?? MIN_IMPRESSIONS));
|
|
116
|
+
const top = o.top ?? 2;
|
|
117
|
+
const winners = eligible.slice(0, top).map((t) => t.concept);
|
|
118
|
+
const losers = eligible.length > top ? [eligible[eligible.length - 1].concept] : [];
|
|
119
|
+
return { winners, losers, table };
|
|
120
|
+
}
|
package/dist/core/state.d.ts
CHANGED
|
@@ -142,7 +142,8 @@ export type Placement = {
|
|
|
142
142
|
externalId: string;
|
|
143
143
|
name: string;
|
|
144
144
|
status: string;
|
|
145
|
-
createdAt: string;
|
|
145
|
+
createdAt: string; /** 규칙이 끈 광고 — 「광고 시작」이 다시 켜지 않는다 */
|
|
146
|
+
pausedBy?: 'rule' | 'user';
|
|
146
147
|
};
|
|
147
148
|
export type ProjectStatus = 'draft' | 'briefed' | 'creatives' | 'planned' | 'built' | 'live' | 'paused' | 'ended';
|
|
148
149
|
export type Project = {
|
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export * from './core/report.js';
|
|
|
24
24
|
export * from './core/tracking.js';
|
|
25
25
|
export * from './core/check.js';
|
|
26
26
|
export * from './adapters/types.js';
|
|
27
|
+
export * from './adapters/meta-targeting.js';
|
|
27
28
|
export { MetaConnector } from './adapters/meta.js';
|
|
28
29
|
export { GoogleConnector, GEO, LANG } from './adapters/google.js';
|
|
29
30
|
export { MockConnector } from './adapters/mock.js';
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export * from './core/report.js';
|
|
|
25
25
|
export * from './core/tracking.js';
|
|
26
26
|
export * from './core/check.js';
|
|
27
27
|
export * from './adapters/types.js';
|
|
28
|
+
export * from './adapters/meta-targeting.js';
|
|
28
29
|
export { MetaConnector } from './adapters/meta.js';
|
|
29
30
|
export { GoogleConnector, GEO, LANG } from './adapters/google.js';
|
|
30
31
|
export { MockConnector } from './adapters/mock.js';
|
package/package.json
CHANGED