adyou 0.6.11 → 0.6.12

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.
@@ -315,8 +315,8 @@ export async function produceConceptVideos(o) {
315
315
  const rawSec = (await probe(rawFile).catch(() => ({ durationSec: job.durationSec }))).durationSec;
316
316
  const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona, fitSec: Math.max(3, rawSec - 0.6), keep: [o.brief.company] });
317
317
  const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
318
- // 말하는 포맷(UGC)인데 원본에 말소리가 있으면(다른 언어·웅얼거림) 겹쳐 들리므로 원본 소리를 버린다 · 장면 포맷은 음악을 남기고 크게 낮춤
319
- const replace = hasSpeech && speakingBoard;
318
+ // 원본에 말소리가 있으면(다른 언어 대사·Veo 영어 웅얼거림) 어느 포맷이든 원본 소리를 버리고 내레이션만 — 낮춰도 영어가 새어 나온다(힐스테이트 글자 훅 「영어 내레이션」 · 2026-09-17 사장님) · 말소리 없는 원본(음악만)은 남기고 덕킹
319
+ const replace = hasSpeech;
320
320
  await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0.2 : speakingBoard ? 0.45 : 0.6, replace });
321
321
  fs.copyFileSync(mixed, rawFile);
322
322
  costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
@@ -20,6 +20,13 @@ export declare function onColor(hex: string): string;
20
20
  /** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
21
21
  /** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
22
22
  export declare function cutWords(text: string, limit: number): string;
23
+ /** 글자 폭 어림(Pretendard 실측 · 에어서울 97px 헤드라인 780px): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
24
+ export declare const textW: (t: string, fs: number) => number;
25
+ /** 헤드라인 줄나눔 — 「오늘의 입술, 틴트 한 / 번」·「나에게 맞는 컬러 / 찾기」처럼 넘치는 자리에서 끊겨 한 어절이 떨어지는 것을 막는다(2026-09-17 사장님).
26
+ * 규칙: 한 줄에 들어가면 그대로 · 두 줄이면 모든 어절 경계 후보 중 (① 쉼표·물음표·마침표·가운뎃점 뒤 우선 ② 두 줄 길이 균형 ③ 마지막 줄이 한 어절(≤4wlen)이면 강한 벌점 ④ 두 줄 모두 폭 안) 비용 최소 · 세 줄 이상은 뒤에서부터 채워 마지막 줄 고아를 피한다 */
27
+ export declare function breakLines(text: string, fs: number, maxW: number, maxLines?: number): string[];
28
+ /** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
29
+ export declare function linesEl(text: string, style: Record<string, unknown>, maxW: number, align?: 'left' | 'center'): El;
23
30
  export declare function wordsEl(text: string, style: Record<string, unknown>, align?: 'left' | 'center'): El;
24
31
  /** 한 규격의 트리 */
25
32
  export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
@@ -107,6 +107,67 @@ export function cutWords(text, limit) {
107
107
  const sp = cut.lastIndexOf(' ');
108
108
  return (sp > limit * 0.5 ? cut.slice(0, sp) : t.slice(0, limit)).replace(/[\s,·:;\-—]+$/, '');
109
109
  }
110
+ /** 글자 폭 어림(Pretendard 실측 · 에어서울 97px 헤드라인 780px): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
111
+ export const textW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 : (ch.codePointAt(0) || 0) > 0x2e7f ? 0.86 : 0.55), 0) * fs;
112
+ /** 헤드라인 줄나눔 — 「오늘의 입술, 틴트 한 / 번」·「나에게 맞는 컬러 / 찾기」처럼 넘치는 자리에서 끊겨 한 어절이 떨어지는 것을 막는다(2026-09-17 사장님).
113
+ * 규칙: 한 줄에 들어가면 그대로 · 두 줄이면 모든 어절 경계 후보 중 (① 쉼표·물음표·마침표·가운뎃점 뒤 우선 ② 두 줄 길이 균형 ③ 마지막 줄이 한 어절(≤4wlen)이면 강한 벌점 ④ 두 줄 모두 폭 안) 비용 최소 · 세 줄 이상은 뒤에서부터 채워 마지막 줄 고아를 피한다 */
114
+ export function breakLines(text, fs, maxW, maxLines = 3) {
115
+ const raw = String(text || '').trim().split(/\s+/).filter(Boolean);
116
+ // 한 글자 어절(「한」「다」「그」)은 다음 어절과 한 덩어리 — 「틴트 한 / 번」 금지
117
+ const words = [];
118
+ for (let i = 0; i < raw.length; i++) {
119
+ if ([...raw[i]].length === 1 && /[가-힣]/.test(raw[i]) && i + 1 < raw.length) {
120
+ words.push(raw[i] + ' ' + raw[i + 1]);
121
+ i++;
122
+ }
123
+ else
124
+ words.push(raw[i]);
125
+ }
126
+ if (!words.length)
127
+ return [];
128
+ const W = (ws) => textW(ws.join(' '), fs);
129
+ if (words.length === 1 || W(words) <= maxW)
130
+ return [words.join(' ')];
131
+ const orphan = (ws) => ws.length === 1 && wlen(ws[0].replace(' ', '')) <= 4;
132
+ let best = null;
133
+ for (let i = 1; i < words.length; i++) {
134
+ const a = words.slice(0, i), b = words.slice(i);
135
+ const wa = W(a), wb = W(b);
136
+ if (wa > maxW || wb > maxW)
137
+ continue;
138
+ let cost = Math.abs(wa - wb) / maxW;
139
+ if (/[,,、.!?!?:·—-]$/.test(a[a.length - 1]))
140
+ cost -= 0.5;
141
+ if (orphan(b) || orphan(a))
142
+ cost += 1;
143
+ if (!best || cost < best.cost)
144
+ best = { lines: [a.join(' '), b.join(' ')], cost };
145
+ }
146
+ if (best)
147
+ return best.lines;
148
+ // 세 줄 이상 — 뒤에서부터 채워 마지막 줄이 짧게 남지 않게
149
+ const lines = [];
150
+ let cur = [];
151
+ for (let i = words.length - 1; i >= 0; i--) {
152
+ const next = [words[i], ...cur];
153
+ if (cur.length && W(next) > maxW) {
154
+ lines.unshift(cur);
155
+ cur = [words[i]];
156
+ }
157
+ else
158
+ cur = next;
159
+ }
160
+ if (cur.length)
161
+ lines.unshift(cur);
162
+ return lines.slice(0, maxLines).map((l) => l.join(' '));
163
+ }
164
+ /** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
165
+ export function linesEl(text, style, maxW, align = 'left') {
166
+ const lines = breakLines(text, Number(style.fontSize || 16), maxW);
167
+ if (lines.length <= 1)
168
+ return wordsEl(text, style, align);
169
+ return h('div', { display: 'flex', flexDirection: 'column', alignItems: align === 'center' ? 'center' : 'flex-start', ...style }, lines.map((l) => h('div', { whiteSpace: 'nowrap' }, l)));
170
+ }
110
171
  export function wordsEl(text, style, align = 'left') {
111
172
  const words = String(text || '').split(/\s+/).filter(Boolean);
112
173
  // 🔴 일본어·중국어는 띄어쓰기가 없어 한 어절이 줄 전체보다 길다 → nowrap 조각이 오른쪽으로 잘림(ISEKAI'D 일본어 엔드카드 · 2026-09-17) → 글자 단위로 줄바꿈
@@ -163,9 +224,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
163
224
  // 세로 예산 — 헤드라인이 두 줄이면 CTA 가 바닥 밖으로 밀린다(힐스테이트 1200×628 실사고 · 2026-09-17) → 줄 수를 어림해 넘치면 헤드라인 축소 → 알약 줄이기 → 본문 한 줄 순으로 맞춘다
164
225
  const colW = Math.round(w * 0.72);
165
226
  const gap = Math.round(pad * 0.5);
166
- // 글자 어림(Pretendard 실측 · 에어서울 97px 헤드라인 780px): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em
167
- const textW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 : (ch.codePointAt(0) || 0) > 0x2e7f ? 0.86 : 0.55), 0) * fs;
168
- const lines = (t, fs) => Math.max(1, Math.ceil(textW(t, fs) / colW));
227
+ const lines = (t, fs) => Math.max(1, breakLines(t, fs, colW).length);
169
228
  const chipRows = () => { let rows = 0, x = 0; for (const t of proofs) {
170
229
  const cw = textW(t, chipFs) + chipFs * 2.4;
171
230
  if (!rows || x + cw > colW) {
@@ -203,14 +262,14 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
203
262
  children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: `0 ${pad}px`, gap: pad }, [brandRow, h('div', { flex: 1, fontSize: headFs, fontWeight: 800, color: fg, letterSpacing: -1, lineHeight: 1.1, whiteSpace: 'nowrap', overflow: 'hidden' }, head), ctaPill]));
204
263
  }
205
264
  else if (narrow) {
206
- children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [wordsEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.15, letterSpacing: -0.8 }), wordsEl(cutWords(body, 60), { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4 })]), ctaPill]));
265
+ children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [linesEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.15, letterSpacing: -0.8 }, w - pad * 2), wordsEl(cutWords(body, 60), { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4 })]), ctaPill]));
207
266
  }
208
267
  else {
209
268
  const isWide = w / hh > 1.6;
210
269
  children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [
211
270
  h('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, [brandRow, domain && !banner ? h('div', { fontSize: Math.round(headFs * 0.3), fontWeight: 500, color: sub }, domain) : h('div', {}, '')]),
212
271
  h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * (banner ? 0.35 : 0.5)), maxWidth: isWide ? Math.round(w * 0.72) : w - pad * 2 }, [
213
- wordsEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }),
272
+ linesEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }, isWide ? Math.round(w * 0.72) : w - pad * 2),
214
273
  (banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }),
215
274
  ...(chips ? [chips] : []),
216
275
  h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
@@ -277,7 +336,7 @@ export async function renderOverlay(file, w, hh, text, o) {
277
336
  : h('div', { display: 'flex', backgroundColor: 'rgba(11,21,38,.62)', borderRadius: Math.round(fs1 * 0.4), padding: `${Math.round(fs1 * 0.35)}px ${Math.round(fs1 * 0.6)}px`, maxWidth: Math.round(w * 0.86) }, [wordsEl(text, { fontSize: fs1, fontWeight: 800, color: '#fff', lineHeight: 1.25, letterSpacing: -1 }, 'center')]);
278
337
  // 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
279
338
  const hook = o.kind === 'hook';
280
- const hookEl = h('div', { display: 'flex', maxWidth: Math.round(w * 0.86), backgroundColor: hexA(o.primary, 0.92), padding: `${Math.round(base * 0.035)}px ${Math.round(base * 0.05)}px`, borderRadius: Math.round(base * 0.03), transform: 'rotate(-2deg)' }, [wordsEl(text, { fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)' }, 'center')]);
339
+ const hookEl = h('div', { display: 'flex', maxWidth: Math.round(w * 0.86), backgroundColor: hexA(o.primary, 0.92), padding: `${Math.round(base * 0.035)}px ${Math.round(base * 0.05)}px`, borderRadius: Math.round(base * 0.03), transform: 'rotate(-2deg)' }, [linesEl(text, { fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)' }, Math.round(w * 0.86) - Math.round(base * 0.05) * 2, 'center')]);
281
340
  const el = hook
282
341
  ? (o.layout === 'lower'
283
342
  ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",