adyou 0.3.0 → 0.5.0
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/meta.d.ts +2 -0
- package/dist/adapters/meta.js +121 -44
- package/dist/adapters/mock.js +24 -10
- package/dist/adapters/types.d.ts +30 -0
- package/dist/adapters/types.js +16 -0
- package/dist/cli.js +2 -2
- package/dist/core/brief.js +12 -3
- package/dist/core/creatives/copy.d.ts +12 -3
- package/dist/core/creatives/copy.js +27 -6
- package/dist/core/creatives/factory.d.ts +29 -0
- package/dist/core/creatives/factory.js +183 -0
- package/dist/core/creatives/images.d.ts +31 -2
- package/dist/core/creatives/images.js +126 -57
- package/dist/core/creatives/media.d.ts +118 -0
- package/dist/core/creatives/media.js +91 -0
- package/dist/core/creatives/playbook.d.ts +40 -0
- package/dist/core/creatives/playbook.js +61 -0
- package/dist/core/creatives/render.d.ts +23 -2
- package/dist/core/creatives/render.js +80 -5
- package/dist/core/creatives/siteshots.d.ts +9 -0
- package/dist/core/creatives/siteshots.js +53 -0
- package/dist/core/creatives/storyboard.d.ts +70 -0
- package/dist/core/creatives/storyboard.js +149 -0
- package/dist/core/creatives/video.d.ts +35 -0
- package/dist/core/creatives/video.js +177 -0
- package/dist/core/creatives/videofx.d.ts +59 -0
- package/dist/core/creatives/videofx.js +125 -0
- package/dist/core/llm.js +9 -2
- package/dist/core/ops.d.ts +6 -0
- package/dist/core/ops.js +38 -6
- package/dist/core/site.d.ts +2 -0
- package/dist/core/site.js +5 -1
- package/dist/core/state.d.ts +43 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/mcp.js +2 -2
- package/package.json +1 -1
package/dist/adapters/meta.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export declare class MetaConnector implements Connector {
|
|
|
15
15
|
ensurePixel(name: string): Promise<string>;
|
|
16
16
|
inventory(): Promise<InventoryRow[]>;
|
|
17
17
|
private findByName;
|
|
18
|
+
/** 영상 업로드(multipart source) → 처리 완료(ready)까지 대기 → video_id. 🔴 크리에이티브는 video_status 가 ready 여야 만들 수 있다 */
|
|
19
|
+
private uploadVideo;
|
|
18
20
|
private uploadImage;
|
|
19
21
|
build(input: BuildInput): Promise<BuildResult>;
|
|
20
22
|
preview(placements: Placement[]): Promise<{
|
package/dist/adapters/meta.js
CHANGED
|
@@ -2,7 +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 { MediumError, nameTag, utm } from './types.js';
|
|
5
|
+
import { abKey, langGroups, localizedCopy, MediumError, nameTag, utm } from './types.js';
|
|
6
6
|
const G = 'https://graph.facebook.com/v23.0';
|
|
7
7
|
const REQUIRED_SCOPES = ['ads_management', 'ads_read', 'business_management', 'pages_show_list', 'pages_read_engagement', 'pages_manage_ads'];
|
|
8
8
|
const COUNTRY_INTERESTS = {};
|
|
@@ -145,6 +145,29 @@ export class MetaConnector {
|
|
|
145
145
|
const d = await this.call('GET', `${this.c.adAccountId}/${edge}`, { fields: 'id,name,status', limit: 500, filtering: [{ field: 'name', operator: 'CONTAIN', value: name.slice(0, 60) }], ...extra });
|
|
146
146
|
return d.data.find((x) => x.name === name) || null;
|
|
147
147
|
}
|
|
148
|
+
/** 영상 업로드(multipart source) → 처리 완료(ready)까지 대기 → video_id. 🔴 크리에이티브는 video_status 가 ready 여야 만들 수 있다 */
|
|
149
|
+
async uploadVideo(file, title, log) {
|
|
150
|
+
const fd = new FormData();
|
|
151
|
+
fd.set('access_token', this.c.accessToken);
|
|
152
|
+
fd.set('title', title.slice(0, 200));
|
|
153
|
+
fd.set('source', new Blob([fs.readFileSync(file)], { type: 'video/mp4' }), 'ad.mp4');
|
|
154
|
+
const res = await fetch(`${G}/${this.c.adAccountId}/advideos`, { method: 'POST', body: fd, signal: AbortSignal.timeout(600_000) });
|
|
155
|
+
const d = (await res.json().catch(() => ({})));
|
|
156
|
+
if (!res.ok || d.error || !d.id)
|
|
157
|
+
throw this.wrap(d.error || { message: `영상 업로드 실패 ${res.status}` }, 'advideos');
|
|
158
|
+
const t0 = Date.now();
|
|
159
|
+
while (Date.now() - t0 < 10 * 60_000) {
|
|
160
|
+
const st = await this.call('GET', d.id, { fields: 'status' });
|
|
161
|
+
const vs = st.status?.video_status;
|
|
162
|
+
if (vs === 'ready')
|
|
163
|
+
return d.id;
|
|
164
|
+
if (vs === 'error')
|
|
165
|
+
throw new MediumError('meta', '메타가 영상을 처리하지 못했어요(코덱·길이 확인)');
|
|
166
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
167
|
+
}
|
|
168
|
+
log(' ⚠ 영상 처리 대기 10분 초과 — 그대로 진행');
|
|
169
|
+
return d.id;
|
|
170
|
+
}
|
|
148
171
|
async uploadImage(file) {
|
|
149
172
|
const d = await this.call('POST', `${this.c.adAccountId}/adimages`, { bytes: fs.readFileSync(file).toString('base64') });
|
|
150
173
|
const first = Object.values(d.images)[0];
|
|
@@ -156,7 +179,7 @@ export class MetaConnector {
|
|
|
156
179
|
const log = input.log || (() => { });
|
|
157
180
|
const warnings = [];
|
|
158
181
|
const placements = [...input.existing];
|
|
159
|
-
const has = (kind, concept) => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept : true));
|
|
182
|
+
const has = (kind, concept, format = 'image', lang) => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept && (p.format || 'image') === format && (!lang || (p.lang || '') === (lang || '')) : true));
|
|
160
183
|
const tag = nameTag(input.slug);
|
|
161
184
|
if (!this.c.pageId)
|
|
162
185
|
throw new MediumError('meta', '페이지 id가 없어요', '`adpilot connect meta --page <페이지 id>`');
|
|
@@ -180,46 +203,100 @@ export class MetaConnector {
|
|
|
180
203
|
// 타겟: 시장 국가 · Advantage+ 오디언스(age_max 금지)
|
|
181
204
|
const targeting = { geo_locations: { countries: input.markets.map((m) => m.country.toUpperCase()) }, age_min: 18, targeting_automation: { advantage_audience: 1 }, ...(input.markets.length === 1 && input.markets[0].language ? { locales: undefined } : {}) };
|
|
182
205
|
const domain = new URL(input.landingUrl).hostname;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
206
|
+
// 언어 그룹 — 같은 언어를 쓰는 나라끼리 광고세트를 나눠 그 언어 문구·자막 소재만 넣는다(언어 섞임 방지 · 시장별 성과 비교)
|
|
207
|
+
const groups = langGroups(input.markets, input.brief.language);
|
|
208
|
+
for (const c of input.concepts)
|
|
209
|
+
for (const g of groups) {
|
|
210
|
+
const L = g.lang;
|
|
211
|
+
const lt = groups.length > 1 ? ` · ${L}` : '';
|
|
212
|
+
const cc = localizedCopy(c, L);
|
|
213
|
+
const forLang = (a) => (a.lang || input.brief.language) === L || (!a.lang && L === input.brief.language);
|
|
214
|
+
const imgs = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && (a.type || 'image') === 'image' && forLang(a));
|
|
215
|
+
const vids = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && a.type === 'video' && forLang(a));
|
|
216
|
+
if (!imgs.length && !vids.length) {
|
|
217
|
+
warnings.push(`${c.key}${lt}: 메타용 소재가 없어 건너뜀`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const gTargeting = { ...targeting, geo_locations: { countries: g.countries } };
|
|
221
|
+
const gl = groups.length > 1 ? L : undefined;
|
|
222
|
+
if (imgs.length) {
|
|
223
|
+
let adset = has('adset', c.key, 'image', gl);
|
|
224
|
+
if (!adset) {
|
|
225
|
+
const name = `${tag} ${c.key} · ${g.countries.join('+')} · DCO`;
|
|
226
|
+
const found = await this.findByName('adsets', name);
|
|
227
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting: gTargeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
|
|
228
|
+
adset = { medium: 'meta', kind: 'adset', concept: c.key, ...(gl ? { lang: gl } : {}), externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
229
|
+
placements.push(adset);
|
|
230
|
+
log(` meta 광고세트 ${c.key}${lt} ${found ? '재사용' : '생성'} ${id}`);
|
|
231
|
+
}
|
|
232
|
+
let creative = has('creative', c.key, 'image', gl);
|
|
233
|
+
if (!creative) {
|
|
234
|
+
const hashes = [];
|
|
235
|
+
for (const a of imgs.filter((x) => [1080].includes(x.w)))
|
|
236
|
+
hashes.push(await this.uploadImage(a.file));
|
|
237
|
+
const name = `${tag} ${c.key}${lt} · DCO`;
|
|
238
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
239
|
+
name,
|
|
240
|
+
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
241
|
+
asset_feed_spec: {
|
|
242
|
+
images: hashes.map((h) => ({ hash: h })), bodies: cc.bodies.slice(0, 5).map((t) => ({ text: t })), titles: cc.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: cc.descriptions.slice(0, 2).map((t) => ({ text: t })),
|
|
243
|
+
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, abKey(c.key, 'image', gl)), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_IMAGE'],
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
creative = { medium: 'meta', kind: 'creative', concept: c.key, ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
247
|
+
placements.push(creative);
|
|
248
|
+
log(` meta 크리에이티브 ${c.key}${lt} 생성 ${d.id} (이미지 ${hashes.length})`);
|
|
249
|
+
}
|
|
250
|
+
if (!has('ad', c.key, 'image', gl)) {
|
|
251
|
+
const name = `${tag} ${c.key}${lt}`;
|
|
252
|
+
const d = await this.call('POST', `${this.c.adAccountId}/ads`, { name, adset_id: adset.externalId, creative: { creative_id: creative.externalId }, status: 'PAUSED', ...(input.pixelId ? { conversion_domain: domain } : {}) });
|
|
253
|
+
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
254
|
+
log(` meta 광고 ${c.key}${lt} 생성 ${d.id}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// 영상 광고 — 컨셉마다 별도 DCO 광고세트(형태 A/B: 이미지 vs 영상) · 9:16 원본 + 파생 비율을 한 크리에이티브의 변형으로
|
|
258
|
+
if (vids.length) {
|
|
259
|
+
let adset = has('adset', c.key, 'video', gl);
|
|
260
|
+
if (!adset) {
|
|
261
|
+
const name = `${tag} ${c.key} · ${g.countries.join('+')} · VIDEO`;
|
|
262
|
+
const found = await this.findByName('adsets', name);
|
|
263
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting: gTargeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
|
|
264
|
+
adset = { medium: 'meta', kind: 'adset', concept: c.key, format: 'video', ...(gl ? { lang: gl } : {}), externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
265
|
+
placements.push(adset);
|
|
266
|
+
log(` meta 영상 광고세트 ${c.key}${lt} ${found ? '재사용' : '생성'} ${id}`);
|
|
267
|
+
}
|
|
268
|
+
let creative = has('creative', c.key, 'video', gl);
|
|
269
|
+
if (!creative) {
|
|
270
|
+
// 포맷(셀카 후기·화면 데모·시네마틱…)별 9:16 원본 + 파생 비율을 한 DCO 크리에이티브의 변형으로(최대 10) — 매체가 포맷·비율을 고른다
|
|
271
|
+
const videos = [];
|
|
272
|
+
const ordered = [...vids].sort((a, b) => (a.ratio === '9x16' ? -1 : 1) - (b.ratio === '9x16' ? -1 : 1));
|
|
273
|
+
for (const v of ordered.slice(0, 10)) {
|
|
274
|
+
const vid = await this.uploadVideo(v.file, `${tag} ${c.key} ${v.format || ''} ${v.ratio}${lt}`, log);
|
|
275
|
+
const thumb = imgs.find((a) => a.ratio === v.ratio && a.w === 1080) || imgs.find((a) => a.w === 1080);
|
|
276
|
+
videos.push({ video_id: vid, ...(thumb ? { thumbnail_hash: await this.uploadImage(thumb.file) } : {}) });
|
|
277
|
+
log(` meta 영상 업로드 ${c.key} ${v.format || ''} ${v.ratio}${lt} → ${vid}`);
|
|
278
|
+
}
|
|
279
|
+
const name = `${tag} ${c.key}${lt} · VIDEO · DCO`;
|
|
280
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
281
|
+
name,
|
|
282
|
+
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
283
|
+
asset_feed_spec: {
|
|
284
|
+
videos, bodies: cc.bodies.slice(0, 5).map((t) => ({ text: t })), titles: cc.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: cc.descriptions.slice(0, 2).map((t) => ({ text: t })),
|
|
285
|
+
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, abKey(c.key, 'video', gl)), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_VIDEO'],
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
creative = { medium: 'meta', kind: 'creative', concept: c.key, format: 'video', ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
289
|
+
placements.push(creative);
|
|
290
|
+
log(` meta 영상 크리에이티브 ${c.key}${lt} 생성 ${d.id} (영상 ${videos.length})`);
|
|
291
|
+
}
|
|
292
|
+
if (!has('ad', c.key, 'video', gl)) {
|
|
293
|
+
const name = `${tag} ${c.key}${lt} · video`;
|
|
294
|
+
const d = await this.call('POST', `${this.c.adAccountId}/ads`, { name, adset_id: adset.externalId, creative: { creative_id: creative.externalId }, status: 'PAUSED', ...(input.pixelId ? { conversion_domain: domain } : {}) });
|
|
295
|
+
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, format: 'video', ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
296
|
+
log(` meta 영상 광고 ${c.key}${lt} 생성 ${d.id}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
221
299
|
}
|
|
222
|
-
}
|
|
223
300
|
return { placements, warnings };
|
|
224
301
|
}
|
|
225
302
|
async preview(placements) {
|
|
@@ -229,7 +306,7 @@ export class MetaConnector {
|
|
|
229
306
|
try {
|
|
230
307
|
const d = await this.call('GET', `${ad.externalId}/previews`, { ad_format: fmt });
|
|
231
308
|
if (d.data[0]?.body)
|
|
232
|
-
out.push({ concept: ad.concept, name: `Meta · ${fmt === 'INSTAGRAM_STANDARD' ? 'Instagram' : 'Facebook 피드'} · ${ad.concept}`, html: d.data[0].body });
|
|
309
|
+
out.push({ concept: ad.concept, name: `Meta · ${fmt === 'INSTAGRAM_STANDARD' ? 'Instagram' : 'Facebook 피드'} · ${ad.concept}${ad.format === 'video' ? ' · 영상' : ''}`, html: d.data[0].body });
|
|
233
310
|
}
|
|
234
311
|
catch (e) {
|
|
235
312
|
out.push({ concept: ad.concept, name: `Meta · ${fmt}`, html: `<p>미리보기 실패: ${e instanceof Error ? e.message : e}</p>` });
|
|
@@ -255,9 +332,9 @@ export class MetaConnector {
|
|
|
255
332
|
if (!camp)
|
|
256
333
|
return [];
|
|
257
334
|
const acc = await this.account();
|
|
258
|
-
const d = await this.call('GET', `${this.c.adAccountId}/insights`, { level: 'ad', fields: 'ad_id,ad_name,adset_id,spend,impressions,clicks,actions', time_range: { since: range.since, until: range.until }, time_increment: 1, filtering: [{ field: 'campaign.id', operator: 'IN', value: [camp.externalId] }], limit: 1000 });
|
|
335
|
+
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 });
|
|
259
336
|
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);
|
|
260
|
-
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, spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), currency: acc.currency }));
|
|
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 }));
|
|
261
338
|
}
|
|
262
339
|
async policy(placements) {
|
|
263
340
|
const out = [];
|
package/dist/adapters/mock.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { HOME } from '../core/state.js';
|
|
5
|
-
import { nameTag } from './types.js';
|
|
5
|
+
import { langGroups, nameTag } from './types.js';
|
|
6
6
|
export class MockConnector {
|
|
7
7
|
medium;
|
|
8
8
|
currency;
|
|
@@ -28,8 +28,8 @@ export class MockConnector {
|
|
|
28
28
|
const st = this.load();
|
|
29
29
|
const placements = [...input.existing];
|
|
30
30
|
const tag = nameTag(input.slug);
|
|
31
|
-
const mk = (kind, concept) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept); if (existing)
|
|
32
|
-
return existing; const p = { medium: this.medium, kind, concept, externalId: `${this.medium}_${kind}_${concept || 'root'}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}`.trim(), status: 'PAUSED', createdAt: new Date().toISOString() }; placements.push(p); st.objects.push(p); return p; };
|
|
31
|
+
const mk = (kind, concept, format, lang) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept && (p.format || 'image') === (format || 'image') && (p.lang || '') === (lang || '')); if (existing)
|
|
32
|
+
return existing; const p = { medium: this.medium, kind, concept, ...(format ? { format } : {}), ...(lang ? { lang } : {}), externalId: `${this.medium}_${kind}_${concept || 'root'}${format === 'video' ? '_video' : ''}${lang ? `_${lang}` : ''}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}${format === 'video' ? ' · video' : ''}${lang ? ` · ${lang}` : ''}`.trim(), status: 'PAUSED', createdAt: new Date().toISOString() }; placements.push(p); st.objects.push(p); return p; };
|
|
33
33
|
if (input.validateOnly) {
|
|
34
34
|
st.calls.push('validate');
|
|
35
35
|
this.save(st);
|
|
@@ -38,13 +38,27 @@ export class MockConnector {
|
|
|
38
38
|
mk('campaign');
|
|
39
39
|
if (this.medium === 'google')
|
|
40
40
|
mk('adgroup');
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
const groups = this.medium === 'meta' ? langGroups(input.markets, input.brief.language) : [{ lang: input.brief.language, countries: input.markets.map((m) => m.country) }];
|
|
42
|
+
for (const c of input.concepts)
|
|
43
|
+
for (const g of groups) {
|
|
44
|
+
const gl = groups.length > 1 ? g.lang : undefined;
|
|
45
|
+
const forLang = (a) => (a.lang || input.brief.language) === g.lang;
|
|
46
|
+
const imgs = input.assets.filter((a) => a.concept === c.key && (a.type || 'image') === 'image' && forLang(a));
|
|
47
|
+
const vids = input.assets.filter((a) => a.concept === c.key && a.type === 'video' && forLang(a));
|
|
48
|
+
if (imgs.length || !vids.length) {
|
|
49
|
+
if (this.medium === 'meta') {
|
|
50
|
+
mk('adset', c.key, 'image', gl);
|
|
51
|
+
mk('creative', c.key, 'image', gl);
|
|
52
|
+
}
|
|
53
|
+
mk('ad', c.key, undefined, gl);
|
|
54
|
+
}
|
|
55
|
+
// 영상은 메타만(구글 디스플레이는 유튜브 영상만 받는다) · 컨셉별 형태 A/B · 언어별
|
|
56
|
+
if (vids.length && this.medium === 'meta') {
|
|
57
|
+
mk('adset', c.key, 'video', gl);
|
|
58
|
+
mk('creative', c.key, 'video', gl);
|
|
59
|
+
mk('ad', c.key, 'video', gl);
|
|
60
|
+
}
|
|
45
61
|
}
|
|
46
|
-
mk('ad', c.key);
|
|
47
|
-
}
|
|
48
62
|
st.calls.push(`build budget=${input.dailyBudgetMinor} ${input.currency}`);
|
|
49
63
|
this.save(st);
|
|
50
64
|
return { placements, warnings: [] };
|
|
@@ -59,7 +73,7 @@ export class MockConnector {
|
|
|
59
73
|
const d0 = new Date(range.since);
|
|
60
74
|
const d1 = new Date(range.until);
|
|
61
75
|
for (let d = new Date(d0); d <= d1; d.setDate(d.getDate() + 1))
|
|
62
|
-
ads.forEach((a, i) => { const imp = 1000 + i * 400; const clicks = Math.round(imp * (0.008 + i * 0.006)); out.push({ date: d.toISOString().slice(0, 10), level: 'ad', id: a.externalId, name: a.name, concept: a.concept, spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
|
|
76
|
+
ads.forEach((a, i) => { const imp = 1000 + i * 400; const clicks = Math.round(imp * (0.008 + i * 0.006)); out.push({ date: d.toISOString().slice(0, 10), level: 'ad', id: a.externalId, name: a.name, concept: a.concept, format: a.format || 'image', lang: a.lang, ...(a.format === 'video' ? { videoPlays: Math.round(imp * 0.6), thruplays: Math.round(imp * (0.12 + i * 0.03)), videoP50: Math.round(imp * 0.2) } : {}), spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
|
|
63
77
|
return out;
|
|
64
78
|
}
|
|
65
79
|
async policy(placements) { return placements.filter((p) => p.medium === this.medium && p.kind === 'ad').map((p) => ({ id: p.externalId, name: p.name, status: 'APPROVED', issues: [] })); }
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -44,6 +44,11 @@ export type MetricRow = {
|
|
|
44
44
|
id: string;
|
|
45
45
|
name: string;
|
|
46
46
|
concept?: string;
|
|
47
|
+
format?: 'image' | 'video';
|
|
48
|
+
lang?: string; /** 영상: 재생 수 · 완주(ThruPlay · 15초 또는 끝까지) · 50% 시청 — 훅률·완주율 계산 */
|
|
49
|
+
videoPlays?: number;
|
|
50
|
+
thruplays?: number;
|
|
51
|
+
videoP50?: number;
|
|
47
52
|
spend: number;
|
|
48
53
|
impressions: number;
|
|
49
54
|
clicks: number;
|
|
@@ -98,3 +103,28 @@ export declare class MediumError extends Error {
|
|
|
98
103
|
}
|
|
99
104
|
export declare const utm: (url: string, medium: Medium, campaign: string, content: string) => string;
|
|
100
105
|
export declare const nameTag: (slug: string) => string;
|
|
106
|
+
/** A/B 키 — 컨셉(+영상이면 _video) · utm_content 와 광고 이름에 같이 쓴다 */
|
|
107
|
+
export declare const abKey: (concept: string, format?: "image" | "video", lang?: string) => string;
|
|
108
|
+
/** 시장을 언어별로 묶는다(같은 언어 나라 = 한 광고세트) · 기본 언어 그룹이 먼저 */
|
|
109
|
+
export declare function langGroups(markets: {
|
|
110
|
+
country: string;
|
|
111
|
+
language: string;
|
|
112
|
+
}[], baseLang: string): {
|
|
113
|
+
lang: string;
|
|
114
|
+
countries: string[];
|
|
115
|
+
}[];
|
|
116
|
+
/** 언어별 문구(컨셉 i18n 이 있으면 치환) */
|
|
117
|
+
export declare function localizedCopy(c: {
|
|
118
|
+
headlines: string[];
|
|
119
|
+
bodies: string[];
|
|
120
|
+
descriptions: string[];
|
|
121
|
+
i18n?: Record<string, {
|
|
122
|
+
headlines: string[];
|
|
123
|
+
bodies: string[];
|
|
124
|
+
descriptions: string[];
|
|
125
|
+
}>;
|
|
126
|
+
}, lang: string): {
|
|
127
|
+
headlines: string[];
|
|
128
|
+
bodies: string[];
|
|
129
|
+
descriptions: string[];
|
|
130
|
+
};
|
package/dist/adapters/types.js
CHANGED
|
@@ -18,3 +18,19 @@ export const utm = (url, medium, campaign, content) => {
|
|
|
18
18
|
return u.toString();
|
|
19
19
|
};
|
|
20
20
|
export const nameTag = (slug) => `[adp:${slug}]`;
|
|
21
|
+
/** A/B 키 — 컨셉(+영상이면 _video) · utm_content 와 광고 이름에 같이 쓴다 */
|
|
22
|
+
export const abKey = (concept, format, lang) => `${format === 'video' ? `${concept}_video` : concept}${lang ? `_${lang}` : ''}`;
|
|
23
|
+
/** 시장을 언어별로 묶는다(같은 언어 나라 = 한 광고세트) · 기본 언어 그룹이 먼저 */
|
|
24
|
+
export function langGroups(markets, baseLang) {
|
|
25
|
+
const m = new Map();
|
|
26
|
+
for (const x of markets) {
|
|
27
|
+
const l = x.language || baseLang;
|
|
28
|
+
m.set(l, [...(m.get(l) || []), x.country.toUpperCase()]);
|
|
29
|
+
}
|
|
30
|
+
return [...m.entries()].map(([lang, countries]) => ({ lang, countries })).sort((a, b) => (a.lang === baseLang ? -1 : 0) - (b.lang === baseLang ? -1 : 0));
|
|
31
|
+
}
|
|
32
|
+
/** 언어별 문구(컨셉 i18n 이 있으면 치환) */
|
|
33
|
+
export function localizedCopy(c, lang) {
|
|
34
|
+
const t = c.i18n?.[lang];
|
|
35
|
+
return t && t.headlines.length ? { headlines: t.headlines, bodies: t.bodies.length ? t.bodies : c.bodies, descriptions: t.descriptions.length ? t.descriptions : c.descriptions } : { headlines: c.headlines, bodies: c.bodies, descriptions: c.descriptions };
|
|
36
|
+
}
|
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', '다시 만들기').action(async (o) => {
|
|
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) => {
|
|
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, log });
|
|
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 });
|
|
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]}`);
|
package/dist/core/brief.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 브리프 — 사이트 스냅샷 → 「이 회사는 ○○를 팔고 고객은 ○○로 보여요」. LLM이 있으면 LLM, 없으면 규칙으로 최소 브리프.
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { aiAvailable, completeJson } from './llm.js';
|
|
4
|
+
import { INDUSTRY_KEYS } from './creatives/playbook.js';
|
|
4
5
|
import { readSite } from './site.js';
|
|
5
6
|
/** 특별 광고 카테고리(정치·금융·주택·고용·의료·도박 등)는 자동 심사 통과가 어려워 접수 거절 */
|
|
6
7
|
const SPECIAL = [
|
|
@@ -21,6 +22,10 @@ const BriefSchema = z.object({
|
|
|
21
22
|
palette: z.object({ primary: z.string(), dark: z.string(), light: z.string() }),
|
|
22
23
|
specialCategory: z.string().nullable().default(null),
|
|
23
24
|
notes: z.string().default(''),
|
|
25
|
+
industry: z.string().default('other'),
|
|
26
|
+
visualGenres: z.array(z.string()).min(1).max(3).default(['photoreal']),
|
|
27
|
+
audienceProfile: z.object({ ageRange: z.string().optional(), gender: z.string().optional(), persona: z.string().optional(), interests: z.array(z.string()).optional(), painPoint: z.string().optional() }).default({}),
|
|
28
|
+
proofPoints: z.array(z.string()).max(6).default([]),
|
|
24
29
|
});
|
|
25
30
|
function pickPalette(s) {
|
|
26
31
|
const primary = (s.themeColor && /^#[0-9a-f]{6}$/i.test(s.themeColor) ? s.themeColor : s.colors.find((c) => !/^#([0-9a-f])\1{5}$/i.test(c) && !/^#(f[0-9a-f]f[0-9a-f]f[0-9a-f]|[0-2][0-9a-f]{5})$/i.test(c))) || '#3182f6';
|
|
@@ -46,13 +51,17 @@ export async function buildBrief(url, opts) {
|
|
|
46
51
|
- language: 광고 카피 기본 언어 코드(사이트 언어 · 시장이 해외면 그 언어)
|
|
47
52
|
- palette: 사이트에서 읽힌 대표색 hex(primary) · 어두운 배경색(dark) · 밝은 배경색(light). 못 읽으면 후보를 그대로.
|
|
48
53
|
- specialCategory: 금융·의료·정치·도박·담배·성인 등 특별 광고 카테고리에 해당하면 그 이름, 아니면 null
|
|
49
|
-
- notes: 광고 만들 때 주의할 점 한두 문장(가격 표기·금지어 등)
|
|
54
|
+
- notes: 광고 만들 때 주의할 점 한두 문장(가격 표기·금지어 등)
|
|
55
|
+
- industry: 업종 키 하나 — ${INDUSTRY_KEYS.join('|')} 중에서(없으면 other)
|
|
56
|
+
- visualGenres: 소재 비주얼 장르 1~3개(우선순위) — photoreal(실사) · ugc(셀카·후기 실사) · anime(애니) · 3d(3D 렌더) · illustration(일러스트) · motion_graphics(모션그래픽) · product_shot(제품 스튜디오). 브랜드가 애니·게임·웹툰이면 anime 를 앞에, 일반 상품·서비스는 photoreal/ugc 를 앞에. 애니 브랜드가 아닌데 anime 를 쓰면 이상하니 넣지 않는다.
|
|
57
|
+
- audienceProfile: ageRange(예 "25-34") · gender(all|female|male) · persona(UGC 배우로 쓸 한 줄 인물 묘사 · 영어) · interests 2~4 · painPoint(한 문장)
|
|
58
|
+
- proofPoints: 숫자·수상·후기·보증 등 사이트에 실제로 있는 증거 문구 0~5(없으면 빈 배열 · 만들지 않는다)`,
|
|
50
59
|
user: JSON.stringify({ url: site.finalUrl, title: site.title, description: site.description, lang: site.lang, headings: site.headings, prices: site.prices, colorsFound: site.colors.slice(0, 8), themeColor: site.themeColor, textExcerpt: site.text.slice(0, 3500), goal: opts.goal, markets: opts.markets, hints: opts.hints || '' }),
|
|
51
60
|
});
|
|
52
|
-
brief = { url: site.finalUrl, ...out, palette: { primary: /^#[0-9a-f]{6}$/i.test(out.palette.primary) ? out.palette.primary.toLowerCase() : fallbackPalette.primary, dark: /^#[0-9a-f]{6}$/i.test(out.palette.dark) ? out.palette.dark : fallbackPalette.dark, light: /^#[0-9a-f]{6}$/i.test(out.palette.light) ? out.palette.light : fallbackPalette.light }, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: out.specialCategory || special };
|
|
61
|
+
brief = { url: site.finalUrl, ...out, palette: { primary: /^#[0-9a-f]{6}$/i.test(out.palette.primary) ? out.palette.primary.toLowerCase() : fallbackPalette.primary, dark: /^#[0-9a-f]{6}$/i.test(out.palette.dark) ? out.palette.dark : fallbackPalette.dark, light: /^#[0-9a-f]{6}$/i.test(out.palette.light) ? out.palette.light : fallbackPalette.light }, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: out.specialCategory || special, siteImages: site.images };
|
|
53
62
|
}
|
|
54
63
|
else {
|
|
55
|
-
brief = { url: site.finalUrl, company: site.title.split(/[|·—\-–:]/)[0].trim() || new URL(site.finalUrl).hostname, offer: site.description || site.headings[0] || site.title, category: '', audience: ['사이트 방문자'], usp: site.headings.slice(0, 3), tone: '단정하고 명확', language: site.lang || 'ko', palette: fallbackPalette, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: special, notes: 'LLM 키가 없어 규칙으로 만든 최소 브리프예요. `adpilot brief --edit` 로 다듬어 주세요.' };
|
|
64
|
+
brief = { url: site.finalUrl, company: site.title.split(/[|·—\-–:]/)[0].trim() || new URL(site.finalUrl).hostname, offer: site.description || site.headings[0] || site.title, category: '', audience: ['사이트 방문자'], usp: site.headings.slice(0, 3), tone: '단정하고 명확', language: site.lang || 'ko', palette: fallbackPalette, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: special, industry: 'other', visualGenres: ['photoreal'], siteImages: site.images, notes: 'LLM 키가 없어 규칙으로 만든 최소 브리프예요. `adpilot brief --edit` 로 다듬어 주세요.' };
|
|
56
65
|
}
|
|
57
66
|
return { brief, site };
|
|
58
67
|
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { Brief, Concept, Goal } from '../state.js';
|
|
2
2
|
import { wlen } from './specs.js';
|
|
3
|
-
export
|
|
3
|
+
export type ConceptOpts = {
|
|
4
4
|
goal: Goal;
|
|
5
5
|
count: number;
|
|
6
6
|
feedback?: string;
|
|
7
|
-
previous?: Concept[];
|
|
8
|
-
|
|
7
|
+
previous?: Concept[]; /** guided: 광고주 요구사항·방향 */
|
|
8
|
+
requirements?: string; /** 참고 이미지 설명(있으면 비주얼을 그 제품·인물에 맞춘다) */
|
|
9
|
+
referenceNote?: string; /** manual: 광고주가 직접 쓴 문구 — 이걸 다듬어 규격에 맞추기만 한다 */
|
|
10
|
+
userCopy?: {
|
|
11
|
+
headlines?: string[];
|
|
12
|
+
bodies?: string[];
|
|
13
|
+
descriptions?: string[];
|
|
14
|
+
}; /** 영상 프롬프트·자막도 만들지 */
|
|
15
|
+
video?: boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function generateConcepts(brief: Brief, opts: ConceptOpts): Promise<Concept[]>;
|
|
9
18
|
/** 매체별 카피 규격 검사 요약 */
|
|
10
19
|
export declare function validateConcepts(concepts: Concept[]): {
|
|
11
20
|
ok: boolean;
|
|
@@ -3,9 +3,10 @@ import { z } from 'zod';
|
|
|
3
3
|
import { aiAvailable, completeJson } from '../llm.js';
|
|
4
4
|
import { checkCopy, ctaFor, fit, GOOGLE_LIMITS, wlen } from './specs.js';
|
|
5
5
|
const ConceptSchema = z.object({
|
|
6
|
-
key: z.string().regex(/^[a-z][a-z0-9_]{1,19}$/), name: z.string(), angle: z.string(),
|
|
7
|
-
headlines: z.array(z.string()).min(
|
|
6
|
+
key: z.string().regex(/^[a-z][a-z0-9_]{1,19}$/), name: z.string().optional(), angle: z.string().optional(),
|
|
7
|
+
headlines: z.array(z.string()).min(1).max(8), bodies: z.array(z.string()).min(1).max(8), descriptions: z.array(z.string()).min(1).max(4),
|
|
8
8
|
imagePrompt: z.string(), theme: z.enum(['dark', 'light']).default('dark'),
|
|
9
|
+
videoPrompt: z.string().optional(), videoLines: z.array(z.string()).optional(), videoVoice: z.string().optional().nullable(),
|
|
9
10
|
});
|
|
10
11
|
export async function generateConcepts(brief, opts) {
|
|
11
12
|
const cta = ctaFor(opts.goal);
|
|
@@ -15,20 +16,38 @@ export async function generateConcepts(brief, opts) {
|
|
|
15
16
|
schema: z.object({ concepts: z.array(ConceptSchema).min(1) }), maxTokens: 6000, timeoutMs: 120_000,
|
|
16
17
|
system: `너는 퍼포먼스 광고 카피라이터이자 아트디렉터다. 브리프로 서로 다른 각도의 광고 컨셉 ${opts.count}개를 만든다(혜택·증거/사회적 증명·긴급/희소·비교/대안·감성 중에서 고르되 겹치지 않게).
|
|
17
18
|
규칙:
|
|
18
|
-
- 언어는 brief.language. 사실은 브리프(usp·offer)에 있는 것만. 과장·최상급(최고·1위·100%·보장·무조건)·클릭 유도 금지.
|
|
19
|
+
- 언어: headlines·bodies·descriptions·videoLines·videoVoice·name·angle 전부 brief.language(${brief.language}) 하나로 — 다른 언어를 섞지 않는다(요구사항이 한국어로 적혀 있어도 출력 언어는 brief.language). 사실은 브리프(usp·offer)에 있는 것만. 과장·최상급(최고·1위·100%·보장·무조건)·클릭 유도 금지.
|
|
19
20
|
- headlines: 짧은 제목 5개 — 각각 ${GOOGLE_LIMITS.headline}자(한글·전각은 2자로 셈 → 한글 15자) 이내. 첫 번째는 그 컨셉의 대표 문장.
|
|
20
21
|
- bodies: 본문 5개 — 60~120자. 구체적 혜택·근거·누구를 위한 것인지. 존댓말(해요체).
|
|
21
22
|
- descriptions: 설명 2개 — 각각 ${GOOGLE_LIMITS.description}자(한글 45자) 이내.
|
|
22
23
|
- imagePrompt: 배경 비주얼 생성 프롬프트(영어). 글자·로고·UI 절대 없음. 헤드라인을 올릴 넉넉한 여백. 브랜드색 ${brief.palette.primary}. 실존 인물·타사 로고 금지.
|
|
23
24
|
- theme: 어두운 배경(dark)이 어울리면 dark, 밝고 가벼우면 light.
|
|
24
|
-
- key: 영문 소문자 짧은 식별자(예 benefit·proof·urgency)
|
|
25
|
-
|
|
25
|
+
- key: 영문 소문자 짧은 식별자(예 benefit·proof·urgency). name: 컨셉 이름(brief.language · 2~6단어). angle: 이 컨셉이 노리는 각도 한 문장.
|
|
26
|
+
- 출력 JSON: {"concepts":[{key,name,angle,headlines[],bodies[],descriptions[],imagePrompt,theme,videoPrompt,videoLines[],videoVoice}]} — 모든 키를 빠짐없이.
|
|
27
|
+
- videoPrompt: 8~15초 짧은 광고 영상 한 컷 연출 프롬프트(영어 · 피사체·동작·카메라·조명·분위기 · 실존 인물·타사 로고·화면 글자 없음 · "No on-screen text, no subtitles, no logos" 로 끝낸다). 소리(잔잔한 BGM·효과음)도 한 줄 지시. 첫 프레임은 imagePrompt 의 비주얼에서 자연스럽게 움직여 나간다고 가정.
|
|
28
|
+
- videoLines: 영상에 얹을 화면 자막 2~3줄(brief.language · 각 18자 이내 · 훅 → 혜택 → 행동 순). videoVoice: 내레이션 한 문장(선택 · brief.language).${opts.requirements ? `\n광고주 요구사항(반드시 반영): 「${opts.requirements}」` : ''}${opts.referenceNote ? `\n참고 이미지: ${opts.referenceNote} — imagePrompt·videoPrompt 는 이 제품/인물이 주인공이 되게 쓴다("the product from the reference image" 처럼 지칭).` : ''}${opts.userCopy ? `\n광고주가 직접 쓴 문구(userCopy)가 있다 — 뜻·톤을 바꾸지 말고 규격(글자 수·정책)에만 맞춰 다듬어 headlines/bodies/descriptions 를 채운다. 컨셉은 그 문구를 나누어 담는다.` : ''}${opts.feedback ? `\n사용자 수정 요청: 「${opts.feedback}」 — 이 요청을 반영해 전부 다시 만든다.` : ''}`,
|
|
29
|
+
user: JSON.stringify({ brief, goal: opts.goal, cta, userCopy: opts.userCopy, previous: opts.previous?.map((c) => ({ key: c.key, name: c.name, angle: c.angle, headline: c.headlines[0] })) }),
|
|
26
30
|
});
|
|
27
|
-
|
|
31
|
+
// 언어 가드 — 시장 언어가 한국어가 아닌데 한글이 섞이면(요구사항·브리프가 한국어라 자주 생김) 한 번 더 그 언어로만 다시 쓴다
|
|
32
|
+
const lang = (brief.language || 'ko').split('-')[0];
|
|
33
|
+
const hasHangul = (t) => /[가-힣]/.test(t);
|
|
34
|
+
const violates = (cs) => lang !== 'ko' && cs.some((c) => [...c.headlines, ...c.bodies, ...c.descriptions, ...(c.videoLines || []), c.videoVoice || ''].some(hasHangul));
|
|
35
|
+
if (violates(out.concepts) && !opts.feedback?.includes('LANGUAGE_FIX')) {
|
|
36
|
+
const fixed = await generateConcepts(brief, { ...opts, feedback: `LANGUAGE_FIX: 직전 답에 한국어가 섞였어요. headlines·bodies·descriptions·videoLines·videoVoice·name·angle 전부 ${brief.language} 로만 다시 써 주세요(브리프·요구사항이 한국어여도 출력은 ${brief.language}).` });
|
|
37
|
+
return fixed;
|
|
38
|
+
}
|
|
39
|
+
const concepts = out.concepts.slice(0, opts.count).map((c) => ({ ...c, name: (c.name || c.headlines[0] || c.key).trim(), angle: (c.angle || c.descriptions[0] || c.headlines[0] || '').trim(), cta: cta.meta, headlines: c.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean).slice(0, 6), descriptions: c.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean).slice(0, 3), bodies: c.bodies.map((b) => b.trim()).filter(Boolean).slice(0, 6), videoLines: (c.videoLines || []).map((l) => l.trim()).filter(Boolean).slice(0, 3), videoVoice: c.videoVoice || undefined }));
|
|
28
40
|
return concepts;
|
|
29
41
|
}
|
|
30
42
|
function fallbackConcepts(brief, opts) {
|
|
31
43
|
const cta = ctaFor(opts.goal);
|
|
44
|
+
const uc = opts.userCopy;
|
|
45
|
+
if (uc && (uc.headlines?.length || uc.bodies?.length)) {
|
|
46
|
+
const heads = (uc.headlines || []).map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean);
|
|
47
|
+
const bodies = (uc.bodies || []).map((b) => b.trim()).filter(Boolean);
|
|
48
|
+
const descs = (uc.descriptions || []).map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean);
|
|
49
|
+
return [{ key: 'mine', name: '내 문구', angle: '광고주가 직접 쓴 문구', cta: cta.meta, theme: 'dark', headlines: heads.length ? heads : [fit(brief.company, GOOGLE_LIMITS.headline)], bodies: bodies.length ? bodies : [brief.offer], descriptions: descs.length ? descs : [fit(brief.offer, GOOGLE_LIMITS.description)], imagePrompt: `Abstract premium background in ${brief.palette.primary}, soft gradients, no text`, videoPrompt: `Slow cinematic push-in on an abstract premium gradient scene in ${brief.palette.primary}, soft light, gentle ambient music. No on-screen text, no subtitles, no logos.`, videoLines: heads.slice(0, 3) }];
|
|
50
|
+
}
|
|
32
51
|
const base = brief.usp.length ? brief.usp : [brief.offer];
|
|
33
52
|
return base.slice(0, opts.count).map((u, i) => ({
|
|
34
53
|
key: ['benefit', 'proof', 'urgency', 'compare', 'emotion'][i] || `c${i}`, name: `컨셉 ${i + 1}`, angle: u, cta: cta.meta, theme: 'dark',
|
|
@@ -36,6 +55,8 @@ function fallbackConcepts(brief, opts) {
|
|
|
36
55
|
bodies: [`${brief.offer} ${brief.company}에서 확인해 보세요.`, `${u}. ${brief.company}.`],
|
|
37
56
|
descriptions: [fit(brief.offer, GOOGLE_LIMITS.description)],
|
|
38
57
|
imagePrompt: `Abstract premium background in ${brief.palette.primary} and deep navy, soft gradients, no text, no logos, generous empty space`,
|
|
58
|
+
videoPrompt: `Slow cinematic camera drift over an abstract premium scene in ${brief.palette.primary} and deep navy, soft volumetric light, calm ambient music. No on-screen text, no subtitles, no logos.`,
|
|
59
|
+
videoLines: [fit(u, 18), fit(brief.offer, 18), '지금 확인해 보세요'].filter(Boolean),
|
|
39
60
|
}));
|
|
40
61
|
}
|
|
41
62
|
/** 매체별 카피 규격 검사 요약 */
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Brief, Concept, CreativeAsset, Goal } from '../state.js';
|
|
2
|
+
import { type VideoJob, type VideoRatio } from './media.js';
|
|
3
|
+
import { type Tone } from './playbook.js';
|
|
4
|
+
export type VideoAsset = CreativeAsset & {
|
|
5
|
+
type: 'video';
|
|
6
|
+
costKrw: number;
|
|
7
|
+
durationSec: number;
|
|
8
|
+
mime: 'video/mp4';
|
|
9
|
+
};
|
|
10
|
+
export type FactoryLog = (s: string) => void;
|
|
11
|
+
export type FactoryInput = {
|
|
12
|
+
dir: string;
|
|
13
|
+
brief: Brief;
|
|
14
|
+
concept: Concept;
|
|
15
|
+
jobs: VideoJob[];
|
|
16
|
+
backgrounds: Record<string, string | null>;
|
|
17
|
+
refs?: string[];
|
|
18
|
+
siteShot?: string | null;
|
|
19
|
+
siteImages?: string[];
|
|
20
|
+
langs?: string[];
|
|
21
|
+
tone?: Tone;
|
|
22
|
+
goal?: Goal;
|
|
23
|
+
log?: FactoryLog;
|
|
24
|
+
onAsset?: (a: VideoAsset) => void | Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
|
|
27
|
+
export declare function renderEndCard(dir: string, brief: Brief, concept: Concept, ratio: VideoRatio, lang: string, backgrounds?: Record<string, string | null>): Promise<string | null>;
|
|
28
|
+
/** 컨셉의 영상 소재 전부(포맷 × 언어 × 비율) — 실패한 포맷은 건너뛰고 로그 · 성공한 건마다 onAsset */
|
|
29
|
+
export declare function produceConceptVideos(o: FactoryInput): Promise<VideoAsset[]>;
|