adyou 0.6.18 → 0.7.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.
@@ -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
+ }
@@ -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
- // 타겟: 시장 국가 · Advantage+ 오디언스(age_max 금지)
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 } : {}) };
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 gTargeting = { ...targeting, geo_locations: { countries: g.countries } };
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 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;
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 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;
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 = [];
@@ -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; /** 영상: 재생 수 · 완주(ThruPlay · 15초 또는 끝까지) · 50% 시청 — 훅률·완주율 계산 */
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
- export type CreativeSource = 'ai' | 'guided' | 'manual';
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
- const imagesEnabled = source !== 'manual' && mode !== 'text';
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
+ }
@@ -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
- const spec = { ...(p.creative || {}), ...(o.spec || {}), ...(o.mode ? { mode: o.mode } : {}), ...(o.requirements ? { source: 'guided', prompt: o.requirements } : {}) };
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
- function mergePlacements(a, b) { const m = new Map(a.map((x) => [`${x.medium}/${x.kind}/${x.concept || ''}`, x])); for (const x of b)
190
- m.set(`${x.medium}/${x.kind}/${x.concept || ''}`, x); return [...m.values()]; }
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))]) {
@@ -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 declare function proposeFor(medium: 'meta' | 'google', rows: MetricRow[], plan: Plan, launchedAt?: string): Proposal[];
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
+ };
@@ -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
- export function proposeFor(medium, rows, plan, launchedAt) {
17
- const ads = summarize(rows);
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 < 48)
24
- return [{ medium, kind: 'note', reason: `광고 시작 ${Math.floor(hours)}시간 뒤 — 학습 단계(48시간)라 소재를 건드리지 않아요.` }];
25
- const eligible = ads.filter((a) => a.impressions >= 2000);
26
- if (eligible.length < 2)
27
- return [{ medium, kind: 'note', reason: '노출 2,000 이상인 소재가 2개 미만 — 비교하기엔 일러요.' }];
28
- const useCpa = eligible.filter((a) => a.conversions > 0).length >= 2 && eligible.reduce((n, a) => n + a.conversions, 0) >= 20;
29
- const score = (a) => (useCpa ? (a.conversions ? -a.cpa : -Infinity) : a.ctr);
30
- const sorted = [...eligible].sort((a, b) => score(b) - score(a));
31
- const best = sorted[0];
32
- const worst = sorted[sorted.length - 1];
33
- const ratio = useCpa ? (best.cpa && worst.cpa ? worst.cpa / best.cpa : 1) : best.ctr && worst.ctr ? best.ctr / worst.ctr : 1;
34
- if (sorted.length >= 2 && ratio >= 1.8)
35
- out.push({ medium, kind: 'pause_ad', target: worst.id, concept: worst.concept, reason: useCpa ? `「${worst.concept || worst.name}」 전환 단가가 1등의 ${ratio.toFixed(1)}배 — 끄고 예산을 잘 되는 쪽에 몰아요` : `「${worst.concept || worst.name}」 클릭률 ${(worst.ctr * 100).toFixed(2)}%가 1등(${(best.ctr * 100).toFixed(2)}%)의 1/${ratio.toFixed(1)} — 끄고 예산을 잘 되는 쪽에 몰아요` });
36
- // 예산: 전체 평균 CTR이 1% 이상이고 예산 소진율이 높으면 +20% (상한 안에서), 소진율 낮으면 유지
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 avgCtr = eligible.reduce((n, a) => n + a.clicks, 0) / Math.max(1, eligible.reduce((n, a) => n + a.impressions, 0));
43
- if (pacing > 0.9 && avgCtr >= 0.01)
44
- out.push({ medium, kind: 'budget_up', reason: `예산을 ${Math.round(pacing * 100)}% 소진하고 클릭률 ${(avgCtr * 100).toFixed(2)}% — 이 매체에 +20% 제안(월 상한 안에서 다른 매체에서 옮김)`, newDailyMinor: Math.floor(pm.dailyBudgetMinor * 1.2) });
45
- else if (pacing < 0.4)
46
- out.push({ medium, kind: 'budget_down', reason: `예산의 ${Math.round(pacing * 100)}%만 쓰고 있어요 — 노출 경쟁이 약하거나 타겟이 좁아요. -20% 후 다른 매체로`, newDailyMinor: Math.floor(pm.dailyBudgetMinor * 0.8) });
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.length)
49
- out.push({ medium, kind: 'note', reason: `소재 간 차이가 크지 않아 그대로 둡니다 (1등 「${best.concept || best.name}」).` });
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.18",
3
+ "version": "0.7.0",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",