adyou 0.6.17 → 0.6.18

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.
@@ -72,6 +72,25 @@ const KO_UI_NOUNS = new Set(['강의', '회의', '문의', '정의', '주의', '
72
72
  const EN_NO_END = new Set(['the', 'a', 'an', 'of', 'to', 'in', 'on', 'for', 'with', 'and', 'or', 'but', 'your', 'our', 'my', 'its', 'their', 'not', 'just', 'at', 'by', 'from', 'into', 'is', 'are', 'be', 'that', 'this', 'very', 'so', 'you\'re', 'we\'re']);
73
73
  const EN_NO_START = new Set(['too', 'either', 'yet']);
74
74
  const isHangul = (s) => /[가-힣]/.test(s);
75
+ /** 부사격 조사로 끝나는 어절(장소·시간·수단·출발점) — 뒤의 짧은 관형형 용언을 꾸민다(「아침에 딴」·「마트에서 산」·「손으로 빚은」) · 「에」 하나짜리도 포함(일반 끊기 보너스엔 안 씀) */
76
+ const KO_ADVERB_FOR_MODIFIER = /[가-힣](에|에서|로|으로|부터|께|에게|한테|와|과|서|처럼|같이|보다)$/;
77
+ /** 자주 쓰는 2음절 관형형 용언(광고 문구) — 명사와 겹치지 않는 것만 */
78
+ const KO_MODIFIER_2 = new Set(['구운', '만든', '고른', '담은', '키운', '빚은', '캐낸', '뽑은', '볶은', '우린', '지은', '튀긴', '삶은', '말린', '재운', '절인', '쪄낸', '끓인', '내린', '갈아낸', '짜낸', '따낸', '건진', '골라낸', '추린', '모은', '익힌', '갓난', '길러낸', '빻은', '깎은', '썰은', '다진', '무친', '데친', '졸인', '지진', '부친', '섞은', '띄운', '숙성한', '엄선한', '수확한', '재배한', '직접', '갓']);
79
+ /** 짧은 관형형 용언인가 — 1음절은 받침 ㄴ·ㄹ(딴·산·쓴·든·본·준·큰·낼·쓸 …)이고 관형사·수사·부사 결속 표에 없는 것 · 2음절은 표에 있는 것 */
80
+ function isShortModifier(w) {
81
+ const chars = [...w.replace(/[^가-힣]/g, '')];
82
+ if (!chars.length || chars.length !== [...w].length)
83
+ return false;
84
+ if (chars.length === 1) {
85
+ if (KO_GLUE_NEXT_STRONG.has(w) || KO_GLUE_NEXT_WEAK.has(w))
86
+ return false;
87
+ const fin = (w.codePointAt(0) - 0xac00) % 28;
88
+ return fin === 4 || fin === 8;
89
+ }
90
+ if (chars.length === 2)
91
+ return KO_MODIFIER_2.has(w);
92
+ return false;
93
+ }
75
94
  const isCJKNoSpace = (s) => /[぀-ヿ㐀-鿿]/.test(s) && !/[가-힣]/.test(s);
76
95
  const PUNCT_END = /[,,、.!?!?::·—–\-…」』’”))]$/;
77
96
  const HARD_PUNCT_END = /[,,、.!?!?::·…]$/;
@@ -82,6 +101,11 @@ export function tokenize(text) {
82
101
  for (let i = 0; i < raw.length; i++) {
83
102
  const w = raw[i];
84
103
  if ([...w].length === 1 && isHangul(w)) {
104
+ // 🔴 「아침에 딴 토마토」 — 한 글자 관형형 용언(딴·산·쓴)은 앞의 부사어(아침에·마트에서)가 꾸미는 말이라 앞에 붙인다(「아침에 / 딴 토마토」 금지 · 2026-09-18 사장님). 「하루에 한 번」의 「한」(수 관형사)은 예전처럼 뒤에 붙는다
105
+ if (words.length && i + 1 < raw.length && isShortModifier(w) && isHangul(raw[i - 1]) && KO_ADVERB_FOR_MODIFIER.test(raw[i - 1]) && !PUNCT_END.test(raw[i - 1])) {
106
+ words[words.length - 1] += ' ' + w;
107
+ continue;
108
+ }
85
109
  if (i + 1 < raw.length) {
86
110
  words.push(w + ' ' + raw[i + 1]);
87
111
  i++;
@@ -103,6 +127,12 @@ export function breakCost(prev, next) {
103
127
  const a = lastWord(prev), b = firstWord(next);
104
128
  const al = a.toLowerCase().replace(/[^a-z']/g, ''), bl = b.toLowerCase().replace(/[^a-z']/g, '');
105
129
  let c = 0;
130
+ // 부연(꾸밈) 관계는 한 어절처럼 — 「아침에 | 딴」(부사어 → 관형형) +0.8 · 「구운 | 빵」(관형형 → 명사) +0.5 · 공간이 허락하면 붙고, 없으면 다른 자리보다 먼저 지킨다
131
+ const bIsMod = isShortModifier(b) && isHangul(next.split(' ')[1] || b) && !PUNCT_END.test(b);
132
+ if (isHangul(a) && isHangul(b) && bIsMod && KO_ADVERB_FOR_MODIFIER.test(a) && !PUNCT_END.test(a))
133
+ return 0.8;
134
+ if (isShortModifier(a) && isHangul(b) && !PUNCT_END.test(a))
135
+ return 0.5;
106
136
  if (HARD_PUNCT_END.test(a))
107
137
  c -= 0.6;
108
138
  else if (PUNCT_END.test(a))
@@ -16,7 +16,7 @@ type El = {
16
16
  type: string;
17
17
  props: Record<string, unknown>;
18
18
  };
19
- /** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
19
+ export declare function bgLuminance(bg: string): number | null;
20
20
  export declare function onColor(hex: string): string;
21
21
  /** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
22
22
  /** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
@@ -38,8 +38,10 @@ async function fetchFont(dir, url, file) {
38
38
  }
39
39
  export async function loadFonts(lang) {
40
40
  const key = lang ? (EXTRA_FONTS[lang] ? lang : EXTRA_FONTS[lang.split('-')[0]] ? lang.split('-')[0] : '') : '';
41
- if (fontCache.has(key))
41
+ if (fontCache.has(key)) {
42
+ registerFonts(fontCache.get(key));
42
43
  return fontCache.get(key);
44
+ } // 캐시 적중에도 측정용 등록(멱등) — 어림 폭으로 떨어지면 「아침에 / 딴 토마토,」 같은 줄나눔이 나온다
43
45
  const dir = path.join(HOME, 'fonts');
44
46
  fs.mkdirSync(dir, { recursive: true });
45
47
  const out = [];
@@ -99,6 +101,35 @@ async function fetchLogo(url, dir) {
99
101
  }
100
102
  const h = (type, style, children, extra = {}) => ({ type, props: { style, ...extra, ...(children === undefined ? {} : { children }) } });
101
103
  /** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
104
+ /** 배경 이미지의 평균 밝기(0~1) — data URI 를 resvg 로 8×8 로 축소 렌더해 픽셀 평균(상대 휘도). 글자 영역(위 65%)만 잰다. 실패하면 null */
105
+ const lumCache = new Map();
106
+ export function bgLuminance(bg) {
107
+ const key = bg.length + ':' + bg.slice(-80);
108
+ if (lumCache.has(key))
109
+ return lumCache.get(key);
110
+ let L = null;
111
+ try {
112
+ const n = 8;
113
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${n}" height="${n}"><image xlink:href="${bg}" href="${bg}" width="${n}" height="${n}" preserveAspectRatio="xMidYMid slice"/></svg>`;
114
+ const img = new Resvg(svg, { fitTo: { mode: 'width', value: n } }).render();
115
+ const px = img.pixels;
116
+ let sum = 0, cnt = 0;
117
+ for (let y = 0; y < Math.ceil(n * 0.65); y++)
118
+ for (let x = 0; x < n; x++) {
119
+ const i = (y * n + x) * 4;
120
+ const a = px[i + 3] / 255;
121
+ const r = px[i] * a + 255 * (1 - a), g = px[i + 1] * a + 255 * (1 - a), b = px[i + 2] * a + 255 * (1 - a);
122
+ sum += (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
123
+ cnt++;
124
+ }
125
+ L = cnt ? sum / cnt : null;
126
+ }
127
+ catch {
128
+ L = null;
129
+ }
130
+ lumCache.set(key, L);
131
+ return L;
132
+ }
102
133
  export function onColor(hex) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; return L > 0.62 ? '#0b1526' : '#ffffff'; }
103
134
  /** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
104
135
  /** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
@@ -148,7 +179,13 @@ function hexA(hex, a) { const m = hex.replace('#', ''); const n = parseInt(m.len
148
179
  /** 한 규격의 트리 */
149
180
  export function tree(size, concept, brief, bg, logo, ctaText) {
150
181
  const { w, h: hh } = size;
151
- const dark = concept.theme === 'dark';
182
+ // 🔴 글자색은 콘셉트 테마가 아니라 실제 배경 밝기로 — 밝은 테마인데 AI 배경이 중간~어두운 톤이면 검은 글자가 배경에 죽는다(2026-09-18 사장님 「배경도 어두운데 텍스트도 어두우니」) → 밝기 0.5 미만이면 흰 글자+어두운 막, 막의 세기도 배경이 중간 톤일수록 강하게
183
+ const L = bg ? bgLuminance(bg) : null;
184
+ const dark = L === null ? concept.theme === 'dark' : L < 0.5;
185
+ const Leff = L ?? (dark ? 0.2 : 0.9);
186
+ // 막 세기(위쪽 · 헤드라인 자리): 어두운 처리는 배경이 밝아질수록(0.25→0.5) 0.15→0.45 · 밝은 처리는 배경이 어두워질수록(0.85→0.5) 0.10→0.55
187
+ const scrimTop = dark ? Math.min(0.5, 0.15 + Math.max(0, Leff - 0.25) * 1.3) : Math.min(0.55, 0.10 + Math.max(0, 0.85 - Leff) * 1.3);
188
+ const scrimBottom = dark ? Math.max(0.75, scrimTop + 0.3) : Math.max(0.55, scrimTop + 0.25);
152
189
  const primary = brief.palette.primary;
153
190
  const base = Math.min(w, hh);
154
191
  const banner = size.kind === 'banner';
@@ -220,7 +257,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
220
257
  const children = [];
221
258
  if (bg)
222
259
  children.push(h('img', { position: 'absolute', top: 0, left: 0, width: w, height: hh, objectFit: 'cover', filter: dark ? 'brightness(.66)' : 'brightness(1.02)' }, undefined, { src: bg, width: w, height: hh }));
223
- children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, backgroundImage: dark ? `linear-gradient(180deg, ${hexA('#0b1526', 0.15)} 0%, ${hexA('#0b1526', 0.75)} 100%)` : `linear-gradient(180deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,.55) 100%)` }));
260
+ children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, backgroundImage: dark ? `linear-gradient(180deg, ${hexA('#0b1526', scrimTop)} 0%, ${hexA('#0b1526', scrimBottom)} 100%)` : `linear-gradient(180deg, rgba(255,255,255,${scrimTop.toFixed(2)}) 0%, rgba(255,255,255,${scrimBottom.toFixed(2)}) 100%)` }));
224
261
  const brandRow = h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [
225
262
  logo ? h('img', { height: short ? Math.round(hh * 0.5) : Math.round(headFs * 0.9), borderRadius: 6 }, undefined, { src: logo }) : h('div', { fontSize: Math.round(headFs * 0.42), fontWeight: 800, color: fg, letterSpacing: -0.5 }, brief.company),
226
263
  ]);
package/dist/core/plan.js CHANGED
@@ -36,11 +36,11 @@ export function computePlan(i) {
36
36
  const explain = [
37
37
  `한 달 ${fmtKrw(monthly)} 을 하루 약 ${fmtKrw(daily)} 으로 나눠 씁니다.`,
38
38
  ...out.map((m) => `· ${MEDIUM_LABEL[m.medium]} 하루 ${fmtKrw(m.dailyBudgetKrw)}${m.currency !== 'KRW' ? ` (계정 통화 ${m.currency} → ${fmtMinor(m.dailyBudgetMinor, m.currency)})` : ''}${m.note ? ` — ${m.note}` : ''}`),
39
- `보여 줄 사람: ${i.markets.map((m) => `${m.country}(${m.language})`).join(' · ')} · 18세 이상 · 매체가 관심사를 자동으로 찾아요(Advantage+ / 자동 타겟).`,
39
+ `보여 줄 사람: ${i.markets.map((m) => `${m.country}(${m.language})`).join(' · ')} · 18세 이상 · 관심사는 매체가 자동으로 찾아요.`,
40
40
  `광고 소재는 ${concepts.length}가지 방향(${concepts.join(', ')})으로 나눠서 어느 쪽이 잘 되는지 비교합니다${i.conceptKeys.length > concepts.length ? ` — 예산이 작아 ${i.conceptKeys.length - concepts.length}개는 뒤로 미뤘어요` : ''}.`,
41
41
  `목표: ${goalWord}${i.hasTracking ? ' — 전환이 50건쯤 쌓이면 전환 최적화로 자동 전환' : ' — 추적 태그가 없어 방문 기준으로만 최적화돼요(추적 코드를 사이트에 넣어 주세요)'}.`,
42
42
  ...(i.markets.some((m) => m.language.split('-')[0] !== (i.briefLanguage || 'ko').split('-')[0]) ? [`⚠ 카피 언어가 ${i.briefLanguage} 인데 시장에 다른 언어(${[...new Set(i.markets.map((m) => m.language))].join(', ')})가 있어요 — 언어별 소재를 따로 만들어 주세요.`] : []),
43
- `만들어지는 광고는 전부 「대기 상태(PAUSED · 노출 )」이고, 광고 시작 전엔 1원도 나가지 않아요.${i.endDate ? ` 종료일 ${i.endDate}.` : ''}`,
43
+ `광고는 전부 「대기 상태(아직보임)」로 만들어지고, 광고 시작 전엔 1원도 나가지 않아요.${i.endDate ? ` 종료일 ${i.endDate}.` : ''}`,
44
44
  ].join('\n');
45
45
  return { monthlyKrw: monthly, dailyKrw: daily, media: out, concepts, markets: i.markets, goal: i.goal, endDate: i.endDate, explain, createdAt: new Date().toISOString() };
46
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",