adyou 0.3.0 → 0.4.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.
@@ -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<{
@@ -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, 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') => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept && (p.format || 'image') === format : 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>`');
@@ -181,43 +204,86 @@ export class MetaConnector {
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
206
  for (const c of input.concepts) {
184
- const imgs = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both'));
185
- if (!imgs.length) {
207
+ const imgs = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && (a.type || 'image') === 'image');
208
+ const vids = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && a.type === 'video');
209
+ if (!imgs.length && !vids.length) {
186
210
  warnings.push(`${c.key}: 메타용 소재가 없어 건너뜀`);
187
211
  continue;
188
212
  }
189
- let adset = has('adset', c.key);
190
- if (!adset) {
191
- const name = `${tag} ${c.key} · ${input.markets.map((m) => m.country).join('+')} · DCO`;
192
- const found = await this.findByName('adsets', name);
193
- const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
194
- adset = { medium: 'meta', kind: 'adset', concept: c.key, externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
195
- placements.push(adset);
196
- log(` meta 광고세트 ${c.key} ${found ? '재사용' : '생성'} ${id}`);
197
- }
198
- let creative = has('creative', c.key);
199
- if (!creative) {
200
- const hashes = [];
201
- for (const a of imgs.filter((x) => [1080].includes(x.w)))
202
- hashes.push(await this.uploadImage(a.file));
203
- const name = `${tag} ${c.key} · DCO`;
204
- const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
205
- name,
206
- object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
207
- asset_feed_spec: {
208
- images: hashes.map((h) => ({ hash: h })), bodies: c.bodies.slice(0, 5).map((t) => ({ text: t })), titles: c.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: c.descriptions.slice(0, 2).map((t) => ({ text: t })),
209
- link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, c.key), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_IMAGE'],
210
- },
211
- });
212
- creative = { medium: 'meta', kind: 'creative', concept: c.key, externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
213
- placements.push(creative);
214
- log(` meta 크리에이티브 ${c.key} 생성 ${d.id} (이미지 ${hashes.length})`);
213
+ if (imgs.length) {
214
+ let adset = has('adset', c.key);
215
+ if (!adset) {
216
+ const name = `${tag} ${c.key} · ${input.markets.map((m) => m.country).join('+')} · DCO`;
217
+ const found = await this.findByName('adsets', name);
218
+ const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
219
+ adset = { medium: 'meta', kind: 'adset', concept: c.key, externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
220
+ placements.push(adset);
221
+ log(` meta 광고세트 ${c.key} ${found ? '재사용' : '생성'} ${id}`);
222
+ }
223
+ let creative = has('creative', c.key);
224
+ if (!creative) {
225
+ const hashes = [];
226
+ for (const a of imgs.filter((x) => [1080].includes(x.w)))
227
+ hashes.push(await this.uploadImage(a.file));
228
+ const name = `${tag} ${c.key} · DCO`;
229
+ const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
230
+ name,
231
+ object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
232
+ asset_feed_spec: {
233
+ images: hashes.map((h) => ({ hash: h })), bodies: c.bodies.slice(0, 5).map((t) => ({ text: t })), titles: c.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: c.descriptions.slice(0, 2).map((t) => ({ text: t })),
234
+ link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, c.key), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_IMAGE'],
235
+ },
236
+ });
237
+ creative = { medium: 'meta', kind: 'creative', concept: c.key, externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
238
+ placements.push(creative);
239
+ log(` meta 크리에이티브 ${c.key} 생성 ${d.id} (이미지 ${hashes.length})`);
240
+ }
241
+ if (!has('ad', c.key)) {
242
+ const name = `${tag} ${c.key}`;
243
+ 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 } : {}) });
244
+ placements.push({ medium: 'meta', kind: 'ad', concept: c.key, externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
245
+ log(` meta 광고 ${c.key} 생성 ${d.id}`);
246
+ }
215
247
  }
216
- if (!has('ad', c.key)) {
217
- const name = `${tag} ${c.key}`;
218
- 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 } : {}) });
219
- placements.push({ medium: 'meta', kind: 'ad', concept: c.key, externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
220
- log(` meta 광고 ${c.key} 생성 ${d.id}`);
248
+ // 영상 광고 — 컨셉마다 별도 DCO 광고세트(형태 A/B: 이미지 vs 영상) · 9:16 원본 + 파생 비율을 한 크리에이티브의 변형으로
249
+ if (vids.length) {
250
+ let adset = has('adset', c.key, 'video');
251
+ if (!adset) {
252
+ const name = `${tag} ${c.key} · ${input.markets.map((m) => m.country).join('+')} · VIDEO`;
253
+ const found = await this.findByName('adsets', name);
254
+ const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
255
+ adset = { medium: 'meta', kind: 'adset', concept: c.key, format: 'video', externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
256
+ placements.push(adset);
257
+ log(` meta 영상 광고세트 ${c.key} ${found ? '재사용' : '생성'} ${id}`);
258
+ }
259
+ let creative = has('creative', c.key, 'video');
260
+ if (!creative) {
261
+ const videos = [];
262
+ for (const v of vids.slice(0, 3)) {
263
+ const vid = await this.uploadVideo(v.file, `${tag} ${c.key} ${v.ratio}`, log);
264
+ const thumb = imgs.find((a) => a.ratio === v.ratio && a.w === 1080) || imgs.find((a) => a.w === 1080);
265
+ videos.push({ video_id: vid, ...(thumb ? { thumbnail_hash: await this.uploadImage(thumb.file) } : {}) });
266
+ log(` meta 영상 업로드 ${c.key} ${v.ratio} → ${vid}`);
267
+ }
268
+ const name = `${tag} ${c.key} · VIDEO · DCO`;
269
+ const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
270
+ name,
271
+ object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
272
+ asset_feed_spec: {
273
+ videos, bodies: c.bodies.slice(0, 5).map((t) => ({ text: t })), titles: c.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: c.descriptions.slice(0, 2).map((t) => ({ text: t })),
274
+ link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, abKey(c.key, 'video')), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_VIDEO'],
275
+ },
276
+ });
277
+ creative = { medium: 'meta', kind: 'creative', concept: c.key, format: 'video', externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
278
+ placements.push(creative);
279
+ log(` meta 영상 크리에이티브 ${c.key} 생성 ${d.id} (영상 ${videos.length})`);
280
+ }
281
+ if (!has('ad', c.key, 'video')) {
282
+ const name = `${tag} ${c.key} · video`;
283
+ 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 } : {}) });
284
+ placements.push({ medium: 'meta', kind: 'ad', concept: c.key, format: 'video', externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
285
+ log(` meta 영상 광고 ${c.key} 생성 ${d.id}`);
286
+ }
221
287
  }
222
288
  }
223
289
  return { placements, warnings };
@@ -229,7 +295,7 @@ export class MetaConnector {
229
295
  try {
230
296
  const d = await this.call('GET', `${ad.externalId}/previews`, { ad_format: fmt });
231
297
  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 });
298
+ out.push({ concept: ad.concept, name: `Meta · ${fmt === 'INSTAGRAM_STANDARD' ? 'Instagram' : 'Facebook 피드'} · ${ad.concept}${ad.format === 'video' ? ' · 영상' : ''}`, html: d.data[0].body });
233
299
  }
234
300
  catch (e) {
235
301
  out.push({ concept: ad.concept, name: `Meta · ${fmt}`, html: `<p>미리보기 실패: ${e instanceof Error ? e.message : e}</p>` });
@@ -257,7 +323,7 @@ export class MetaConnector {
257
323
  const acc = await this.account();
258
324
  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 });
259
325
  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 }));
326
+ 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', spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), currency: acc.currency }));
261
327
  }
262
328
  async policy(placements) {
263
329
  const out = [];
@@ -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) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept && (p.format || 'image') === (format || 'image')); if (existing)
32
+ return existing; const p = { medium: this.medium, kind, concept, ...(format ? { format } : {}), externalId: `${this.medium}_${kind}_${concept || 'root'}${format === 'video' ? '_video' : ''}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}${format === 'video' ? ' · video' : ''}`.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);
@@ -39,11 +39,21 @@ export class MockConnector {
39
39
  if (this.medium === 'google')
40
40
  mk('adgroup');
41
41
  for (const c of input.concepts) {
42
- if (this.medium === 'meta') {
43
- mk('adset', c.key);
44
- mk('creative', c.key);
42
+ const imgs = input.assets.filter((a) => a.concept === c.key && (a.type || 'image') === 'image');
43
+ const vids = input.assets.filter((a) => a.concept === c.key && a.type === 'video');
44
+ if (imgs.length || !vids.length) {
45
+ if (this.medium === 'meta') {
46
+ mk('adset', c.key);
47
+ mk('creative', c.key);
48
+ }
49
+ mk('ad', c.key);
50
+ }
51
+ // 영상은 메타만(구글 디스플레이는 유튜브 영상만 받는다) · 컨셉별 형태 A/B
52
+ if (vids.length && this.medium === 'meta') {
53
+ mk('adset', c.key, 'video');
54
+ mk('creative', c.key, 'video');
55
+ mk('ad', c.key, 'video');
45
56
  }
46
- mk('ad', c.key);
47
57
  }
48
58
  st.calls.push(`build budget=${input.dailyBudgetMinor} ${input.currency}`);
49
59
  this.save(st);
@@ -59,7 +69,7 @@ export class MockConnector {
59
69
  const d0 = new Date(range.since);
60
70
  const d1 = new Date(range.until);
61
71
  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 }); });
72
+ 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', spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
63
73
  return out;
64
74
  }
65
75
  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: [] })); }
@@ -44,6 +44,7 @@ export type MetricRow = {
44
44
  id: string;
45
45
  name: string;
46
46
  concept?: string;
47
+ format?: 'image' | 'video';
47
48
  spend: number;
48
49
  impressions: number;
49
50
  clicks: number;
@@ -98,3 +99,5 @@ export declare class MediumError extends Error {
98
99
  }
99
100
  export declare const utm: (url: string, medium: Medium, campaign: string, content: string) => string;
100
101
  export declare const nameTag: (slug: string) => string;
102
+ /** A/B 키 — 컨셉(+영상이면 _video) · utm_content 와 광고 이름에 같이 쓴다 */
103
+ export declare const abKey: (concept: string, format?: "image" | "video") => string;
@@ -18,3 +18,5 @@ 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) => (format === 'video' ? `${concept}_video` : concept);
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]}`);
@@ -1,11 +1,20 @@
1
1
  import type { Brief, Concept, Goal } from '../state.js';
2
2
  import { wlen } from './specs.js';
3
- export declare function generateConcepts(brief: Brief, opts: {
3
+ export type ConceptOpts = {
4
4
  goal: Goal;
5
5
  count: number;
6
6
  feedback?: string;
7
- previous?: Concept[];
8
- }): Promise<Concept[]>;
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(3).max(6), bodies: z.array(z.string()).min(2).max(6), descriptions: z.array(z.string()).min(1).max(3),
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).${opts.feedback ? `\n사용자 수정 요청: 「${opts.feedback}」 요청을 반영해 전부 다시 만든다.` : ''}`,
25
- user: JSON.stringify({ brief, goal: opts.goal, cta, previous: opts.previous?.map((c) => ({ key: c.key, name: c.name, angle: c.angle, headline: c.headlines[0] })) }),
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
- const concepts = out.concepts.slice(0, opts.count).map((c) => ({ ...c, cta: cta.meta, headlines: c.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean), descriptions: c.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean), bodies: c.bodies.map((b) => b.trim()).filter(Boolean) }));
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,22 @@
1
+ import type { Brief, Concept, CreativeAsset } from '../state.js';
2
+ import { type VideoJob, type VideoRatio } from './media.js';
3
+ export type VideoAsset = CreativeAsset & {
4
+ type: 'video';
5
+ costKrw: number;
6
+ durationSec: number;
7
+ mime: 'video/mp4';
8
+ };
9
+ export type FactoryLog = (s: string) => void;
10
+ /** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(기존 satori 트리 재사용) */
11
+ export declare function renderEndCard(dir: string, brief: Brief, concept: Concept, ratio: VideoRatio): Promise<string | null>;
12
+ /** 컨셉의 영상 소재 전부(계획된 비율·길이) — 실패한 건은 건너뛰고 로그 · 성공한 건마다 onAsset */
13
+ export declare function produceConceptVideos(o: {
14
+ dir: string;
15
+ brief: Brief;
16
+ concept: Concept;
17
+ jobs: VideoJob[];
18
+ backgrounds: Record<string, string | null>;
19
+ refs?: string[];
20
+ log?: FactoryLog;
21
+ onAsset?: (a: VideoAsset) => void | Promise<void>;
22
+ }): Promise<VideoAsset[]>;
@@ -0,0 +1,112 @@
1
+ // 소재 공장 — 컨셉 하나의 영상 소재를 끝까지 만든다: 첫 프레임(같은 비율의 생성 배경) → 영상 생성(Veo/Seedance · 네이티브 소리) → 자막 번인(헤드라인 훅·혜택·CTA) → 엔드카드(로고·CTA 2초) → 파생 비율(4:5·1:1 무료 크롭).
2
+ // CLI(ops.ts)와 웹(pipeline.ts)이 같은 함수를 부른다. 원가는 onAsset 으로 건별 통보(청구는 호출자 · ×1.1).
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { DERIVED_RATIOS, VIDEO_DIMS } from './media.js';
6
+ import { ctaLabel, renderConcept, renderOverlay } from './render.js';
7
+ import { generateVideo } from './video.js';
8
+ import { appendEndCard, burnCaptions, deriveRatio, hasFfmpeg, probe } from './videofx.js';
9
+ /** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(기존 satori 트리 재사용) */
10
+ export async function renderEndCard(dir, brief, concept, ratio) {
11
+ try {
12
+ const { w, h } = VIDEO_DIMS[ratio];
13
+ const out = await renderConcept(dir, brief, concept, {}, { force: false, only: [{ w, h, ratio, medium: 'meta', kind: 'poster' }] });
14
+ return out[0]?.file || null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ /** 컨셉의 영상 소재 전부(계획된 비율·길이) — 실패한 건은 건너뛰고 로그 · 성공한 건마다 onAsset */
21
+ export async function produceConceptVideos(o) {
22
+ const log = o.log || (() => { });
23
+ const c = o.concept;
24
+ const out = [];
25
+ const ff = await hasFfmpeg();
26
+ if (!ff)
27
+ log(' ⚠ ffmpeg 가 없어 자막·엔드카드·비율 파생 없이 원본 영상만 써요');
28
+ const cta = ctaLabel(c.cta, o.brief.language);
29
+ for (const job of o.jobs) {
30
+ const base = path.join(o.dir, `${c.key}_video_${job.ratio}`);
31
+ const finalFile = `${base}.mp4`;
32
+ if (fs.existsSync(finalFile)) {
33
+ const p = ff ? await probe(finalFile) : { durationSec: job.durationSec, w: VIDEO_DIMS[job.ratio].w, h: VIDEO_DIMS[job.ratio].h };
34
+ out.push({ concept: c.key, w: VIDEO_DIMS[job.ratio].w, h: VIDEO_DIMS[job.ratio].h, ratio: job.ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(p.durationSec), mime: 'video/mp4', costKrw: 0, origin: 'ai' });
35
+ continue;
36
+ }
37
+ const firstFrame = o.backgrounds[job.ratio] || (job.ratio === '9x16' ? o.backgrounds['4x5'] : job.ratio === '16x9' ? o.backgrounds['1x1'] : null) || undefined;
38
+ const prompt = `${c.videoPrompt || `A cinematic 8-second product/brand advertisement scene inspired by: ${c.imagePrompt}. Slow, elegant camera movement.`}${job.audio ? ' Subtle ambient background music and light sound design that fits the mood.' : ' Silent, no music, no sound.'} No on-screen text, no subtitles, no captions, no logos, no watermark.`;
39
+ log(`「${c.name}」 영상 ${job.ratio.replace('x', ':')} ${job.durationSec}초 만드는 중… (${job.provider === 'veo' ? 'Veo 3.1' : 'Seedance 2.5'} · 첫 프레임 ${firstFrame ? '생성 배경' : '없음'})`);
40
+ let raw;
41
+ let costKrw = 0;
42
+ let durationSec = job.durationSec;
43
+ try {
44
+ const r = await generateVideo({ file: `${base}.raw.mp4`, prompt, ratio: job.ratio, durationSec: job.durationSec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: firstFrame ? undefined : o.refs, negative: 'text, subtitles, captions, letters, watermark, logo, blurry, distorted faces, extra fingers', log });
45
+ raw = r.file;
46
+ costKrw = r.costKrw;
47
+ durationSec = r.durationSec;
48
+ log(` 영상 생성 완료 · 원가 ₩${Math.round(costKrw).toLocaleString()}`);
49
+ }
50
+ catch (e) {
51
+ const ck = Number(e.costKrw || 0);
52
+ log(` ❌ 영상 ${job.ratio} 실패 — ${e instanceof Error ? e.message.slice(0, 200) : e}${ck ? ` (원가 ₩${ck} 발생)` : ''}`);
53
+ if (ck > 0)
54
+ await o.onAsset?.({ concept: c.key, w: 0, h: 0, ratio: job.ratio, file: '', medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw: ck, origin: 'ai' });
55
+ continue;
56
+ }
57
+ let cur = raw;
58
+ if (ff) {
59
+ try {
60
+ if (job.captions && (c.videoLines?.length || c.headlines.length)) {
61
+ const lines = (c.videoLines?.length ? c.videoLines : [c.headlines[0], c.bodies[0]?.slice(0, 22) || '']).filter(Boolean).slice(0, 3);
62
+ const pr = await probe(cur);
63
+ const seg = pr.durationSec / lines.length;
64
+ const overlays = [];
65
+ for (const [i, t] of lines.entries())
66
+ overlays.push({ png: await renderOverlay(path.join(o.dir, `${c.key}_${job.ratio}_cap${i}.png`), pr.w, pr.h, t, { kind: 'line', primary: o.brief.palette.primary }), start: i * seg, end: (i + 1) * seg - (i === lines.length - 1 ? 0 : 0.12) });
67
+ overlays.push({ png: await renderOverlay(path.join(o.dir, `${c.key}_${job.ratio}_cta.png`), pr.w, pr.h, cta, { kind: 'cta', primary: o.brief.palette.primary }), start: Math.max(0, pr.durationSec - seg), end: pr.durationSec + 1 });
68
+ const withCap = `${base}.cap.mp4`;
69
+ await burnCaptions(cur, withCap, { overlays });
70
+ cur = withCap;
71
+ log(' 자막·행동 버튼 오버레이 ✓');
72
+ }
73
+ if (job.endCard) {
74
+ const card = await renderEndCard(o.dir, o.brief, c, job.ratio);
75
+ if (card) {
76
+ const withEnd = `${base}.end.mp4`;
77
+ await appendEndCard(cur, card, withEnd, 2);
78
+ cur = withEnd;
79
+ durationSec += 2;
80
+ log(' 엔드카드 ✓');
81
+ }
82
+ }
83
+ }
84
+ catch (e) {
85
+ log(` ⚠ 후처리 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 160) : e}`);
86
+ }
87
+ }
88
+ fs.copyFileSync(cur, finalFile);
89
+ const dims = VIDEO_DIMS[job.ratio];
90
+ const asset = { concept: c.key, w: dims.w, h: dims.h, ratio: job.ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw, origin: 'ai' };
91
+ out.push(asset);
92
+ await o.onAsset?.(asset);
93
+ if (ff)
94
+ for (const dr of DERIVED_RATIOS[job.ratio]) {
95
+ if (o.jobs.some((j) => j.ratio === dr))
96
+ continue;
97
+ const df = path.join(o.dir, `${c.key}_video_${dr}.mp4`);
98
+ try {
99
+ await deriveRatio(finalFile, df, dr);
100
+ const d2 = VIDEO_DIMS[dr];
101
+ const a2 = { concept: c.key, w: d2.w, h: d2.h, ratio: dr, file: df, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: 0, origin: 'ai' };
102
+ out.push(a2);
103
+ await o.onAsset?.(a2);
104
+ log(` ${dr.replace('x', ':')} 파생 ✓ (무료)`);
105
+ }
106
+ catch (e) {
107
+ log(` ⚠ ${dr} 파생 실패: ${e instanceof Error ? e.message.slice(0, 120) : e}`);
108
+ }
109
+ }
110
+ }
111
+ return out;
112
+ }
@@ -1,9 +1,38 @@
1
+ export type ImageProvider = 'bizrouter' | 'openai';
1
2
  export declare function imageConfig(): {
2
3
  key: string;
3
4
  model: string;
5
+ base: string;
6
+ provider: ImageProvider;
4
7
  } | null;
5
8
  export declare function imagesAvailable(): boolean;
6
- /** 컨셉 × 비율 배경 생성 → 파일 경로. 이미 있으면 건너뜀(재실행 안전). */
7
- export declare function generateBackgrounds(dir: string, conceptKey: string, prompt: string, ratios?: string[], log?: (s: string) => void): Promise<Record<string, string | null>>;
9
+ export declare const isMockGen: () => boolean;
10
+ export type ImageResult = {
11
+ file: string;
12
+ costKrw: number;
13
+ model: string;
14
+ };
15
+ /** 이미지 한 장 — 파일로 저장 · 원가(원) 반환. 실패는 예외(fatal 이면 재시도 없이) */
16
+ export declare function generateImage(opts: {
17
+ file: string;
18
+ prompt: string;
19
+ ratio: string;
20
+ model?: string;
21
+ refs?: string[];
22
+ log?: (s: string) => void;
23
+ quality?: 'low' | 'medium' | 'high';
24
+ }): Promise<ImageResult>;
25
+ /** 컨셉 × 비율 배경 생성 → 파일 경로. 이미 있으면 건너뜀(재실행 안전). 원가는 onAsset 으로 알린다. */
26
+ export declare function generateBackgrounds(dir: string, conceptKey: string, prompt: string, ratios?: string[], log?: (s: string) => void, opts?: {
27
+ model?: string;
28
+ refs?: string[];
29
+ variant?: string;
30
+ onAsset?: (a: {
31
+ ratio: string;
32
+ file: string;
33
+ costKrw: number;
34
+ model: string;
35
+ }) => void | Promise<void>;
36
+ }): Promise<Record<string, string | null>>;
8
37
  /** 사용자 이미지 폴더 → 비율별 배경 매핑(가장 가까운 비율) */
9
38
  export declare function userBackgrounds(folder: string, conceptKey: string): Record<string, string | null>;