adyou 0.5.0 → 0.5.2

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.
@@ -5,12 +5,13 @@ import { INDUSTRY_KEYS } from './creatives/playbook.js';
5
5
  import { readSite } from './site.js';
6
6
  /** 특별 광고 카테고리(정치·금융·주택·고용·의료·도박 등)는 자동 심사 통과가 어려워 접수 거절 */
7
7
  const SPECIAL = [
8
- [/대출|카드론|신용|투자|주식|코인|암호화폐|보험|loan|crypto|forex|insurance|invest/i, '금융(대출·투자·보험·암호화폐)'],
8
+ // 🔴 넓은 단어 매칭은 오탐(항공 「성인 1명」·여행자 보험 안내·「신용카드 결제」) → 상품 문맥이 붙을 때만
9
+ [/대출|카드론|신용대출|주식\s*투자|코인\s*거래|암호화폐|보험\s*(가입|상품|료|설계)|\bloan|crypto|forex|insurance\s*(plan|quote)|투자\s*(상품|수익)/i, '금융(대출·투자·보험·암호화폐)'],
9
10
  [/카지노|베팅|도박|casino|betting|gambl/i, '도박'],
10
11
  [/정당|선거|후보|political|election/i, '정치·선거'],
11
- [/처방|치료제|병원|의약|clinic|pharma|treatment/i, '의료·의약'],
12
+ [/처방|치료제|의약품|병원\s*(진료|예약|시술)|성형|시술\s*(예약|비용)|clinic|pharma|treatment/i, '의료·의약'],
12
13
  [/담배|전자담배|vape|tobacco/i, '담배'],
13
- [/성인|adult\s*content|19금/i, '성인'],
14
+ [/성인\s*(용품|콘텐츠|채널|사이트|영상|방송)|adult\s*(content|toys?|site)|19금|청소년\s*이용\s*불가/i, '성인'],
14
15
  ];
15
16
  export function detectSpecialCategory(text) { for (const [r, n] of SPECIAL)
16
17
  if (r.test(text))
@@ -9,7 +9,7 @@ import { genreStyle, TONE_WORDS } from './playbook.js';
9
9
  import { ctaLabel, renderConcept, renderOverlay, renderPhoneStage } from './render.js';
10
10
  import { langName, localizedBoard, localizedConcept, planStoryboards, translateCopy } from './storyboard.js';
11
11
  import { generateVideo } from './video.js';
12
- import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, probe, uiDemoClip } from './videofx.js';
12
+ import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, pickHookBand, probe, tailLoudnessDb, uiDemoClip } from './videofx.js';
13
13
  /** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
14
14
  export async function renderEndCard(dir, brief, concept, ratio, lang, backgrounds = {}) {
15
15
  try {
@@ -25,16 +25,18 @@ export async function renderEndCard(dir, brief, concept, ratio, lang, background
25
25
  }
26
26
  }
27
27
  /** 자막 오버레이 묶음 — 훅(0~2.4초 · 상단) → 자막 줄(균등) → CTA(마지막 구간) */
28
- async function overlaysFor(dir, key, dims, text, cta, primary, durationSec, lang) {
28
+ async function overlaysFor(dir, key, dims, text, cta, primary, durationSec, lang, layout = 'top') {
29
29
  const out = [];
30
30
  const hookEnd = Math.min(2.4, durationSec * 0.3);
31
+ // 사람 얼굴이 나오는 포맷은 훅을 상단(얼굴 위)이 아니라 가슴 아래(자막 자리)에 — 얼굴을 가리지 않게
31
32
  if (text.hook)
32
- out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_hook.png`), dims.w, dims.h, text.hook, { kind: 'hook', primary, lang }), start: 0, end: hookEnd });
33
- const lines = text.captions.filter(Boolean).slice(0, 3);
33
+ out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_hook.png`), dims.w, dims.h, text.hook, { kind: 'hook', primary, lang, layout }), start: 0, end: hookEnd });
34
+ // 훅과 같은 문장인 자막 줄은 빼고(중복) · 훅이 자막 자리(lower)에 있으면 겹치지 않게 훅이 끝난 뒤 시작
35
+ const norm = (t) => t.replace(/[\s.,!?、。!?「」"']/g, '').toLowerCase();
36
+ const lines = text.captions.filter(Boolean).filter((t) => norm(t) !== norm(text.hook)).slice(0, 3);
34
37
  if (lines.length) {
35
- const from = text.hook ? hookEnd - 0.2 : 0;
38
+ const from = text.hook ? (layout === 'lower' ? hookEnd + 0.05 : hookEnd - 0.2) : 0;
36
39
  const seg = (durationSec - from) / lines.length;
37
- lines.forEach((t, i) => { void t; });
38
40
  for (const [i, t] of lines.entries())
39
41
  out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_cap${i}.png`), dims.w, dims.h, t, { kind: 'line', primary, lang }), start: from + i * seg, end: from + (i + 1) * seg - (i === lines.length - 1 ? 0 : 0.1) });
40
42
  }
@@ -72,14 +74,22 @@ export async function produceConceptVideos(o) {
72
74
  const board = c.storyboards.find((b) => b.format === format) || c.storyboards[0];
73
75
  const dims = VIDEO_DIMS[job.ratio];
74
76
  const key = `${c.key}_${format}`;
75
- const rawFile = path.join(o.dir, `${key}_${job.ratio}.raw.mp4`);
76
- let costKrw = 0;
77
- if (!fs.existsSync(rawFile)) {
78
- log(`「${c.name}」 ${format} 영상 ${job.ratio.replace('x', ':')} ${job.durationSec}초 — 훅 「${board.hook}」 (${job.provider === 'veo' ? 'Veo 3.1' : 'Seedance 2.5'} · 장르 ${board.genre})`);
77
+ const speaks = board.clips.some((cl) => cl.kind === 'gen' && cl.speech);
78
+ // 말하는 포맷(UGC)은 언어마다 그 언어 대사로 클립을 따로 생성(🔴 자막만 바꾸면 음성은 원어 그대로 — 2026-09-16 사장님 검수) · 말하지 않는 포맷은 원본 하나를 언어별 자막으로 공유
79
+ const rawLangs = speaks ? langs : [langs[0]];
80
+ const costByLang = {};
81
+ const rawFor = (lang) => path.join(o.dir, speaks ? `${key}_${lang}_${job.ratio}.raw.mp4` : `${key}_${job.ratio}.raw.mp4`);
82
+ for (const rl of rawLangs) {
83
+ const rawFile = rawFor(rl);
84
+ let costKrw = 0;
85
+ if (fs.existsSync(rawFile))
86
+ continue;
87
+ const lb0 = localizedBoard(board, c.i18n, rl);
88
+ log(`「${c.name}」 ${format} 영상 ${job.ratio.replace('x', ':')} ${job.durationSec}초${speaks ? ` · ${langName(rl)} 대사` : ''} — 훅 「${lb0.hook}」 (${job.provider === 'veo' ? 'Veo 3.1' : 'Seedance 2.5'} · 장르 ${board.genre})`);
79
89
  const clipFiles = [];
80
90
  let failed = false;
81
91
  for (const [ci, clip] of board.clips.entries()) {
82
- const cf = path.join(o.dir, `${key}_${job.ratio}_c${ci}.mp4`);
92
+ const cf = path.join(o.dir, `${key}_${rl}_${job.ratio}_c${ci}.mp4`);
83
93
  try {
84
94
  if (clip.kind === 'ui_demo') {
85
95
  if (!ff)
@@ -95,11 +105,40 @@ export async function produceConceptVideos(o) {
95
105
  }
96
106
  else {
97
107
  const person = format === 'ugc_selfie';
98
- const speech = clip.speech ? ` The person speaks to camera in ${langName(board.lang)}: "${clip.speech}". Natural lip sync, conversational, genuine.` : '';
108
+ const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
109
+ // 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
110
+ const speech = line ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}, not English unless the line is English): "${line}". Natural lip sync, conversational, genuine, native-speaker accent. The line is short and finishes comfortably by second ${Math.max(3, clip.sec - 2)}; for the final 1.5 seconds the person is silent, smiling or nodding at the camera (no speech cut-off).` : '';
99
111
  const prompt = `${clip.prompt}${speech} Style: ${genreStyle(board.genre)}. Camera: ${tw.camera}. Audio: ${job.audio ? `${board.music || tw.music}${person ? ', clear voice over the music' : ''}` : 'silent'}. No on-screen text, no subtitles, no captions, no logos, no watermark.`;
100
112
  const firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
101
- const r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: !firstFrame && !person ? o.refs?.slice(0, 3) : undefined, negative: 'text, subtitles, captions, letters, watermark, logo, blurry, distorted face, extra fingers', log });
113
+ let r;
114
+ try {
115
+ r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: !firstFrame && !person ? o.refs?.slice(0, 3) : undefined, negative: 'text, subtitles, captions, letters, watermark, logo, blurry, distorted face, extra fingers', log });
116
+ }
117
+ catch (e) {
118
+ if (!e.retryable)
119
+ throw e;
120
+ costKrw += Number(e.costKrw || 0);
121
+ log(` 안전 필터로 결과가 비어 와서 다른 테이크로 한 번 더 만들어요`);
122
+ r = await generateVideo({ file: cf, prompt: `${prompt} Alternative take: different framing and wardrobe, softer lighting, keep everything family-friendly and brand-safe.`, ratio: job.ratio, durationSec: clip.sec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
123
+ }
102
124
  costKrw += r.costKrw;
125
+ // 대사 꼬리 검사 — 마지막 0.7초가 아직 시끄러우면(≥ -27dB) 말이 끝까지 이어진 것 → 더 짧은 대사로 1회 재생성(원가 1회 추가)
126
+ if (line && ff) {
127
+ const tail = await tailLoudnessDb(cf, 0.7);
128
+ if (tail >= -27) {
129
+ log(` 대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB) → 더 짧게 한 번 더 만들어요`);
130
+ const short = line.split(/(?<=[.!?。!?])\s+/)[0] || line;
131
+ try {
132
+ const r2 = await generateVideo({ file: cf, prompt: prompt.replace(`"${line}"`, `"${short}"`).replace('finishes comfortably by second', 'finishes clearly by second'), ratio: job.ratio, durationSec: clip.sec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
133
+ costKrw += r2.costKrw;
134
+ const t2 = await tailLoudnessDb(cf, 0.7);
135
+ log(` 다시 만든 컷 꼬리 ${t2.toFixed(0)}dB${t2 >= -27 ? ' (여전히 이어짐 · 그대로 사용)' : ' ✓'}`);
136
+ }
137
+ catch (e) {
138
+ log(` ⚠ 재생성 실패(첫 테이크 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
139
+ }
140
+ }
141
+ }
103
142
  log(` 컷 ${ci + 1}/${board.clips.length} ${clip.sec}초 ✓ 원가 ₩${Math.round(r.costKrw).toLocaleString()}`);
104
143
  }
105
144
  clipFiles.push(cf);
@@ -114,9 +153,10 @@ export async function produceConceptVideos(o) {
114
153
  break;
115
154
  }
116
155
  }
156
+ costByLang[rl] = costKrw;
117
157
  if (failed || !clipFiles.length) {
118
158
  if (costKrw > 0)
119
- await o.onAsset?.({ concept: c.key, w: 0, h: 0, ratio: job.ratio, file: '', medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw, origin: 'ai', format });
159
+ await o.onAsset?.({ concept: c.key, w: 0, h: 0, ratio: job.ratio, file: '', medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw, origin: 'ai', format, lang: rl });
120
160
  continue;
121
161
  }
122
162
  try {
@@ -131,11 +171,16 @@ export async function produceConceptVideos(o) {
131
171
  }
132
172
  }
133
173
  // 3) 언어 × 비율 마감 — 원본(raw)에서 비율을 먼저 만들고 그 위에 자막·엔드카드
134
- const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
135
174
  for (const lang of langs) {
175
+ const rawFile = rawFor(lang);
176
+ if (!fs.existsSync(rawFile)) {
177
+ log(` ⚠ ${lang} 원본이 없어 건너뜀`);
178
+ continue;
179
+ }
136
180
  const lb = localizedBoard(board, c.i18n, lang);
137
181
  const lc = localizedConcept(c, c.i18n, lang);
138
182
  const cta = ctaLabel(c.cta, lang);
183
+ const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
139
184
  for (const ratio of ratios) {
140
185
  const d = VIDEO_DIMS[ratio];
141
186
  const finalFile = path.join(o.dir, `${key}_${lang}_${ratio}.mp4`);
@@ -146,14 +191,18 @@ export async function produceConceptVideos(o) {
146
191
  const pr0 = await probe(rawFile);
147
192
  durationSec = pr0.durationSec;
148
193
  if (ratio !== job.ratio) {
149
- const df = path.join(o.dir, `${key}_${ratio}.raw.mp4`);
194
+ const df = path.join(o.dir, `${key}_${speaks ? lang + '_' : ''}${ratio}.raw.mp4`);
150
195
  if (!fs.existsSync(df))
151
196
  await deriveRatio(rawFile, df, ratio);
152
197
  cur = df;
153
198
  }
154
199
  if (job.captions) {
200
+ // 훅 자리: 사람 포맷은 가슴 아래 · 그 외는 첫 프레임 분석(피사체가 있는 띠를 피함 · 상단 우선)
201
+ const layout = format === 'ugc_selfie' || format === 'lifestyle' ? 'lower' : await pickHookBand(cur);
202
+ if (lang === langs[0] && ratio === job.ratio)
203
+ log(` 훅 자리: ${layout === 'top' ? '상단' : layout === 'middle' ? '중앙' : '하단'}(첫 프레임 분석)`);
155
204
  const withCap = path.join(o.dir, `${key}_${lang}_${ratio}.cap.mp4`);
156
- await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang) });
205
+ await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang, layout) });
157
206
  cur = withCap;
158
207
  }
159
208
  if (job.endCard) {
@@ -171,13 +220,14 @@ export async function produceConceptVideos(o) {
171
220
  }
172
221
  }
173
222
  fs.copyFileSync(cur, finalFile);
174
- const primary = lang === langs[0] && ratio === job.ratio;
175
- const asset = { concept: c.key, w: d.w, h: d.h, ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: primary ? costKrw : 0, origin: 'ai', format, lang, variant: `${format}_${lang}` };
223
+ const primary = ratio === job.ratio && (speaks ? true : lang === langs[0]);
224
+ const asset = { concept: c.key, w: d.w, h: d.h, ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: primary ? (costByLang[speaks ? lang : langs[0]] || 0) : 0, origin: 'ai', format, lang, variant: `${format}_${lang}` };
176
225
  out.push(asset);
177
226
  await o.onAsset?.(asset);
178
227
  }
179
228
  }
180
- log(` ${format} 완성 언어 ${langs.join('·')} × 비율 ${ratios.map((r) => r.replace('x', ':')).join('·')}${costKrw ? ` · 생성 원가 ₩${Math.round(costKrw).toLocaleString()}` : ''}`);
229
+ const costKrw = Object.values(costByLang).reduce((n, v) => n + v, 0);
230
+ log(` ${format} 완성 — 언어 ${langs.join('·')}${speaks ? '(언어별 대사 클립)' : '(자막만 언어별)'} × 비율 ${[job.ratio, ...DERIVED_RATIOS[job.ratio]].map((r) => r.replace('x', ':')).join('·')}${costKrw ? ` · 생성 원가 ₩${Math.round(costKrw).toLocaleString()}` : ''}`);
181
231
  }
182
232
  return out;
183
233
  }
@@ -15,6 +15,8 @@ type El = {
15
15
  type: string;
16
16
  props: Record<string, unknown>;
17
17
  };
18
+ /** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
19
+ export declare function onColor(hex: string): string;
18
20
  /** 한 규격의 트리 */
19
21
  export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
20
22
  export declare const CTA_LABEL: Record<string, Record<string, string>>;
@@ -31,7 +33,8 @@ export declare function renderLogo(dir: string, brief: Brief): Promise<CreativeA
31
33
  export declare function renderOverlay(file: string, w: number, hh: number, text: string, o: {
32
34
  kind: 'line' | 'cta' | 'hook';
33
35
  primary: string;
34
- lang?: string;
36
+ lang?: string; /** 훅 위치 — top(기본) · lower(사람 얼굴이 나오는 포맷: 가슴 아래 · 자막 자리) */
37
+ layout?: 'top' | 'middle' | 'lower';
35
38
  }): Promise<string>;
36
39
  /** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
37
40
  export declare function renderPhoneStage(dir: string, w: number, hh: number, primary: string, dark: string): Promise<{
@@ -95,6 +95,8 @@ async function fetchLogo(url, dir) {
95
95
  }
96
96
  }
97
97
  const h = (type, style, children, extra = {}) => ({ type, props: { style, ...extra, ...(children === undefined ? {} : { children }) } });
98
+ /** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
99
+ 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'; }
98
100
  function hexA(hex, a) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; }
99
101
  /** 한 규격의 트리 */
100
102
  export function tree(size, concept, brief, bg, logo, ctaText) {
@@ -128,7 +130,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
128
130
  const brandRow = h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [
129
131
  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),
130
132
  ]);
131
- const ctaPill = h('div', { display: 'flex', alignItems: 'center', fontSize: ctaFs, fontWeight: 700, color: '#fff', backgroundColor: primary, borderRadius: 999, padding: `${Math.round(ctaFs * 0.5)}px ${Math.round(ctaFs * 1.1)}px`, ...(banner ? {} : { boxShadow: `0 10px 30px ${hexA(primary, 0.45)}` }) }, ctaText);
133
+ const ctaPill = h('div', { display: 'flex', alignItems: 'center', fontSize: ctaFs, fontWeight: 700, color: onColor(primary), backgroundColor: primary, borderRadius: 999, padding: `${Math.round(ctaFs * 0.5)}px ${Math.round(ctaFs * 1.1)}px`, ...(banner ? {} : { boxShadow: `0 10px 30px ${hexA(primary, 0.45)}` }) }, ctaText);
132
134
  if (short) {
133
135
  // 가로 띠: 로고 | 헤드라인 | CTA
134
136
  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]));
@@ -202,13 +204,17 @@ export async function renderOverlay(file, w, hh, text, o) {
202
204
  const base = Math.min(w, hh);
203
205
  const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
204
206
  const pill = o.kind === 'cta'
205
- ? h('div', { display: 'flex', fontSize: fs1, fontWeight: 700, color: '#fff', backgroundColor: o.primary, borderRadius: 999, padding: `${Math.round(fs1 * 0.55)}px ${Math.round(fs1 * 1.3)}px`, boxShadow: `0 10px 30px ${hexA(o.primary, 0.45)}` }, text)
207
+ ? h('div', { display: 'flex', fontSize: fs1, fontWeight: 700, color: onColor(o.primary), backgroundColor: o.primary, borderRadius: 999, padding: `${Math.round(fs1 * 0.55)}px ${Math.round(fs1 * 1.3)}px`, boxShadow: `0 10px 30px ${hexA(o.primary, 0.45)}` }, text)
206
208
  : h('div', { display: 'flex', fontSize: fs1, fontWeight: 800, color: '#fff', 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`, lineHeight: 1.25, letterSpacing: -1, textAlign: 'center', maxWidth: Math.round(w * 0.86), wordBreak: 'keep-all' }, text);
207
209
  // 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
208
210
  const hook = o.kind === 'hook';
209
- const hookEl = h('div', { display: 'flex', fontSize: Math.round(base * 0.095), fontWeight: 800, color: '#fff', lineHeight: 1.12, letterSpacing: -1.5, textAlign: 'center', maxWidth: Math.round(w * 0.86), wordBreak: 'keep-all', textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)', 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)' }, text);
211
+ const hookEl = h('div', { display: 'flex', fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textAlign: 'center', maxWidth: Math.round(w * 0.86), wordBreak: 'keep-all', textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)', 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)' }, text);
210
212
  const el = hook
211
- ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), fontFamily: FONT_FAMILY }, [hookEl])
213
+ ? (o.layout === 'lower'
214
+ ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
215
+ : o.layout === 'middle'
216
+ ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: FONT_FAMILY }, [hookEl])
217
+ : h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), fontFamily: FONT_FAMILY }, [hookEl]))
212
218
  : h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * (o.kind === 'cta' ? 0.12 : 0.24)), fontFamily: FONT_FAMILY }, [pill]);
213
219
  const svg = await satori(el, { width: w, height: hh, fonts });
214
220
  fs.writeFileSync(file, new Resvg(svg, { fitTo: { mode: 'width', value: w } }).render().asPng());
@@ -24,7 +24,8 @@ export type Storyboard = {
24
24
  export type LocalizedBoard = {
25
25
  hook: string;
26
26
  captions: string[];
27
- voice?: string;
27
+ voice?: string; /** 말하는 포맷(UGC)의 대사 — 언어별 클립을 따로 생성할 때 쓴다 */
28
+ speech?: string;
28
29
  };
29
30
  export type LocalizedCopy = {
30
31
  headlines: string[];
@@ -57,7 +57,7 @@ export async function generateStoryboards(brief, concept, o) {
57
57
  규칙:
58
58
  - boards 는 요청 포맷마다 정확히 1개. 각 board: format · genre(위 장르 중 하나) · hook(첫 프레임 큰 글자 · ${lang} · 6단어/12자 이내 · 질문/숫자/반전) · captions(화면 자막 3줄 · ${lang} · 각 18자 이내 · 훅→가치/증명→행동 순 · 마지막 줄은 행동 유도) · voice(선택 · ${lang} 한 문장 내레이션/대사) · music(영어 한 줄).
59
59
  - clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
60
- - ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 8초에 맞는 한 문장(${lang}) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
60
+ - ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 한 문장(영어 ≤ 12단어 · 한국어/일본어 ≤ 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다(뚝 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
61
61
  - ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
62
62
  - 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
63
63
  - 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
@@ -118,9 +118,9 @@ export async function translateCopy(brief, concept, boards, langs) {
118
118
  return out;
119
119
  }
120
120
  const r = await completeJson({
121
- schema: z.object({ translations: z.record(z.string(), z.object({ headlines: z.array(z.string()), bodies: z.array(z.string()), descriptions: z.array(z.string()), boards: z.array(z.object({ id: z.string(), hook: z.string(), captions: z.array(z.string()), voice: z.string().optional().nullable() })).optional() })) }), maxTokens: 6000, timeoutMs: 150_000,
122
- system: `너는 광고 현지화 카피라이터다. 아래 문구·자막·훅·대사를 각 언어로 옮긴다. 직역이 아니라 그 시장 광고에서 자연스러운 표현으로, 뜻·사실·숫자는 그대로. 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자(한글·전각 2자) · 설명 ≤ ${GOOGLE_LIMITS.description}자 · 자막 각 18자(영문 32자) 이내 · 훅 6단어 이내. 브랜드명(${brief.company})은 번역하지 않는다. 출력 JSON {"translations":{"<lang>":{headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice}]}}}`,
123
- user: JSON.stringify({ sourceLanguage: brief.language, targets: targets.map((t) => `${t} (${langName(t)})`), headlines: concept.headlines, bodies: concept.bodies, descriptions: concept.descriptions, boards: boards.map((b) => ({ id: b.id, hook: b.hook, captions: b.captions, voice: b.voice })) }),
121
+ schema: z.object({ translations: z.record(z.string(), z.object({ headlines: z.array(z.string()), bodies: z.array(z.string()), descriptions: z.array(z.string()), boards: z.array(z.object({ id: z.string(), hook: z.string(), captions: z.array(z.string()), voice: z.string().optional().nullable(), speech: z.string().optional().nullable() })).optional() })) }), maxTokens: 6000, timeoutMs: 150_000,
122
+ system: `너는 광고 현지화 카피라이터다. 아래 문구·자막·훅·대사를 각 언어로 옮긴다. 직역이 아니라 그 시장 광고에서 자연스러운 표현으로, 뜻·사실·숫자는 그대로. 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자(한글·전각 2자) · 설명 ≤ ${GOOGLE_LIMITS.description}자 · 자막 각 18자(영문 32자) 이내 · 훅 6단어 이내 · 대사(voice·speech)는 말하면 4~5초 안에 끝나는 짧은 한 문장(영어 ≤ 12단어 · 일본어 ≤ 22자 · 그 언어 원어민이 셀카에서 실제로 쓰는 구어체). 브랜드명(${brief.company})은 번역하지 않는다. 출력 JSON {"translations":{"<lang>":{headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice,speech}]}}}`,
123
+ user: JSON.stringify({ sourceLanguage: brief.language, targets: targets.map((t) => `${t} (${langName(t)})`), headlines: concept.headlines, bodies: concept.bodies, descriptions: concept.descriptions, boards: boards.map((b) => ({ id: b.id, hook: b.hook, captions: b.captions, voice: b.voice, speech: b.clips.find((c) => c.speech)?.speech })) }),
124
124
  });
125
125
  for (const l of targets) {
126
126
  const t = r.translations[l] || r.translations[l.split('-')[0]];
@@ -130,7 +130,7 @@ export async function translateCopy(brief, concept, boards, langs) {
130
130
  for (const b of boards) {
131
131
  const tb = t.boards?.find((x) => x.id === b.id);
132
132
  if (tb)
133
- out[l].boards = { ...(out[l].boards || {}), [b.id]: { hook: fitHook(tb.hook), captions: tb.captions.slice(0, 3), voice: tb.voice || undefined } };
133
+ out[l].boards = { ...(out[l].boards || {}), [b.id]: { hook: fitHook(tb.hook), captions: tb.captions.slice(0, 3), voice: tb.voice || undefined, speech: tb.speech || undefined } };
134
134
  }
135
135
  }
136
136
  return out;
@@ -138,7 +138,7 @@ export async function translateCopy(brief, concept, boards, langs) {
138
138
  /** 언어별 콘티 자막/훅 꺼내기(없으면 원문) */
139
139
  export function localizedBoard(b, i18n, lang) {
140
140
  const t = i18n?.[lang]?.boards?.[b.id];
141
- return t ? { hook: t.hook, captions: t.captions, voice: t.voice } : { hook: b.hook, captions: b.captions, voice: b.voice };
141
+ return t ? { hook: t.hook, captions: t.captions, voice: t.voice, speech: t.speech } : { hook: b.hook, captions: b.captions, voice: b.voice, speech: b.clips.find((c) => c.speech)?.speech };
142
142
  }
143
143
  /** 언어별 컨셉(문구 치환) — 이미지 렌더·매체 카피에 */
144
144
  export function localizedConcept(c, i18n, lang) {
@@ -67,8 +67,9 @@ async function veo(i, cfg) {
67
67
  const s = (await st.json().catch(() => ({})));
68
68
  if (s.cost_krw)
69
69
  costKrw = s.cost_krw;
70
+ // 🔴 Veo 「No video in response」 = 안전 필터(실사 인물·식품 등)로 결과가 비어 옴 · 접수비는 발생 → 호출자가 다른 테이크로 1회 재시도(retryable)
70
71
  if (s.status === 'failed')
71
- throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw });
72
+ throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw, retryable: /No video in response|filtered|safety/i.test(String(s.error || '')) });
72
73
  if (s.status === 'completed') {
73
74
  const dl = await fetch(`${cfg.base}/v1/video/generation/${acc.operation_id}/download`, { headers: H, signal: AbortSignal.timeout(300_000) });
74
75
  if (!dl.ok)
@@ -18,7 +18,7 @@ export declare function burnCaptions(input: string, output: string, o: {
18
18
  end: number;
19
19
  }[];
20
20
  }): Promise<void>;
21
- /** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 소리는 유지(엔드카드 구간은 무음). */
21
+ /** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 하드컷 대신 소리 페이드아웃(0.6초) + 0.35초 디졸브 — 대사가 끝나는 순간 「뚝」 끊기는 느낌을 줄인다. */
22
22
  export declare function appendEndCard(input: string, cardPng: string, output: string, seconds?: number): Promise<void>;
23
23
  /** 비율 파생 — 중앙 크롭 후 표준 크기로 스케일(9:16 → 4:5 · 1:1, 16:9 → 1:1) */
24
24
  export declare function deriveRatio(input: string, output: string, ratio: VideoRatio): Promise<void>;
@@ -57,3 +57,10 @@ export declare function kenBurnsClip(image: string, output: string, o: {
57
57
  h: number;
58
58
  };
59
59
  }): Promise<void>;
60
+ /**
61
+ * 훅 자막 자리 고르기 — 첫 1초 프레임을 작게(36×64 그레이) 읽어 세 띠(상단 8~34% · 중앙 38~62% · 하단 58~80%)의 디테일(이웃 픽셀 차 합)을 비교해 가장 비어 있는 띠를 고른다.
62
+ * 피사체(얼굴·제품)는 디테일이 높고 하늘·배경은 낮다. 하단은 매체 UI(20%)와 자막 자리와 겹치므로 상단·중앙이 같은 값이면 상단 우선. 실패하면 'top'.
63
+ */
64
+ export declare function pickHookBand(video: string, atSec?: number): Promise<'top' | 'middle' | 'lower'>;
65
+ /** 마지막 tailSec 초의 평균 음량(dBFS) — 대사가 끝까지 이어져 「뚝」 끊기는지 판정(무음·음악만이면 낮음). 실패 시 -99. */
66
+ export declare function tailLoudnessDb(file: string, tailSec?: number): Promise<number>;
@@ -54,19 +54,30 @@ export async function burnCaptions(input, output, o) {
54
54
  const args = ['-y', '-v', 'error', ...inputs, '-filter_complex', fc, '-map', '[v]', ...(p.hasAudio ? ['-map', '0:a', '-c:a', 'copy'] : []), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', output];
55
55
  await execFileP('ffmpeg', args, { maxBuffer: 8 << 20 });
56
56
  }
57
- /** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 소리는 유지(엔드카드 구간은 무음). */
57
+ /** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 하드컷 대신 소리 페이드아웃(0.6초) + 0.35초 디졸브 — 대사가 끝나는 순간 「뚝」 끊기는 느낌을 줄인다. */
58
58
  export async function appendEndCard(input, cardPng, output, seconds = 2) {
59
59
  const p = await probe(input);
60
60
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-end-'));
61
61
  const card = path.join(dir, 'card.mp4');
62
- await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-t', String(seconds), '-i', cardPng, '-f', 'lavfi', '-t', String(seconds), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', `scale=${p.w}:${p.h}:force_original_aspect_ratio=increase,crop=${p.w}:${p.h},format=yuv420p,fps=24`, '-c:v', 'libx264', '-preset', 'veryfast', '-c:a', 'aac', '-shortest', card]);
62
+ const xf = 0.35;
63
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-t', String(seconds + xf), '-i', cardPng, '-f', 'lavfi', '-t', String(seconds + xf), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', `scale=${p.w}:${p.h}:force_original_aspect_ratio=increase,crop=${p.w}:${p.h},format=yuv420p,fps=24`, '-c:v', 'libx264', '-preset', 'veryfast', '-c:a', 'aac', '-shortest', card]);
63
64
  const main = path.join(dir, 'main.mp4');
64
- // 본편도 같은 코덱·프레임레이트·오디오로 정규화(무음이면 무음 트랙 추가) concat 필터
65
+ // 본편 정규화(24fps · yuv420p · 48k 스테레오 · 무음이면 무음 트랙) + 0.6초 소리 페이드아웃
66
+ const d = Math.max(0.5, p.durationSec);
67
+ const fadeSt = Math.max(0, d - 0.6).toFixed(2);
65
68
  const args = p.hasAudio
66
- ? ['-y', '-v', 'error', '-i', input, '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', main]
67
- : ['-y', '-v', 'error', '-i', input, '-f', 'lavfi', '-t', String(p.durationSec), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', main];
69
+ ? ['-y', '-v', 'error', '-i', input, '-vf', 'fps=24,format=yuv420p', '-af', `afade=t=out:st=${fadeSt}:d=0.6`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', main]
70
+ : ['-y', '-v', 'error', '-i', input, '-f', 'lavfi', '-t', String(d), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', main];
68
71
  await execFileP('ffmpeg', args, { maxBuffer: 8 << 20 });
69
- await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', main, '-i', card, '-filter_complex', '[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]', '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
72
+ const md = (await probe(main)).durationSec;
73
+ const offset = Math.max(0.1, md - xf).toFixed(2);
74
+ try {
75
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', main, '-i', card, '-filter_complex', `[0:v][1:v]xfade=transition=fade:duration=${xf}:offset=${offset}[v];[0:a][1:a]acrossfade=d=${xf}[a]`, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
76
+ }
77
+ catch {
78
+ // xfade 가 안 되는 입력(가변 fps 등)이면 하드컷 concat 로 폴백
79
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', main, '-i', card, '-filter_complex', '[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]', '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
80
+ }
70
81
  fs.rmSync(dir, { recursive: true, force: true });
71
82
  }
72
83
  /** 비율 파생 — 중앙 크롭 후 표준 크기로 스케일(9:16 → 4:5 · 1:1, 16:9 → 1:1) */
@@ -123,3 +134,45 @@ export async function kenBurnsClip(image, output, o) {
123
134
  const frames = Math.round(o.sec * fps);
124
135
  await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-framerate', String(fps), '-t', String(o.sec), '-i', image, '-f', 'lavfi', '-t', String(o.sec), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', `scale=${o.dims.w * 2}:${o.dims.h * 2}:force_original_aspect_ratio=increase,crop=${o.dims.w * 2}:${o.dims.h * 2},zoompan=z='min(zoom+0.0009,1.12)':d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${o.dims.w}x${o.dims.h}:fps=${fps},format=yuv420p`, '-frames:v', String(frames), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
125
136
  }
137
+ /**
138
+ * 훅 자막 자리 고르기 — 첫 1초 프레임을 작게(36×64 그레이) 읽어 세 띠(상단 8~34% · 중앙 38~62% · 하단 58~80%)의 디테일(이웃 픽셀 차 합)을 비교해 가장 비어 있는 띠를 고른다.
139
+ * 피사체(얼굴·제품)는 디테일이 높고 하늘·배경은 낮다. 하단은 매체 UI(20%)와 자막 자리와 겹치므로 상단·중앙이 같은 값이면 상단 우선. 실패하면 'top'.
140
+ */
141
+ export async function pickHookBand(video, atSec = 0.6) {
142
+ try {
143
+ const W = 36, H = 64;
144
+ const { stdout } = await execFileP('ffmpeg', ['-v', 'error', '-ss', String(atSec), '-i', video, '-frames:v', '1', '-vf', `scale=${W}:${H}`, '-pix_fmt', 'gray', '-f', 'rawvideo', '-'], { encoding: 'buffer', maxBuffer: 1 << 20 });
145
+ const px = stdout;
146
+ if (px.length < W * H)
147
+ return 'top';
148
+ const detail = (y0, y1) => { let d = 0, n = 0; for (let y = Math.floor(y0 * H); y < Math.floor(y1 * H) - 1; y++)
149
+ for (let x = 0; x < W - 1; x++) {
150
+ const i = y * W + x;
151
+ d += Math.abs(px[i] - px[i + 1]) + Math.abs(px[i] - px[i + W]);
152
+ n++;
153
+ } return n ? d / n : 0; };
154
+ const bands = { top: detail(0.08, 0.34), middle: detail(0.38, 0.62), lower: detail(0.58, 0.80) };
155
+ const order = ['top', 'middle', 'lower'].sort((a, b) => bands[a] - bands[b]);
156
+ // 상단이 최소이거나 최소와 15% 안이면 상단(관례·안전영역) · 아니면 최소 띠
157
+ const best = order[0];
158
+ if (best === 'top' || bands.top <= bands[best] * 1.15)
159
+ return 'top';
160
+ return best;
161
+ }
162
+ catch {
163
+ return 'top';
164
+ }
165
+ }
166
+ /** 마지막 tailSec 초의 평균 음량(dBFS) — 대사가 끝까지 이어져 「뚝」 끊기는지 판정(무음·음악만이면 낮음). 실패 시 -99. */
167
+ export async function tailLoudnessDb(file, tailSec = 0.7) {
168
+ try {
169
+ const p = await probe(file);
170
+ const st = Math.max(0, p.durationSec - tailSec);
171
+ const { stderr } = await execFileP('ffmpeg', ['-v', 'info', '-ss', st.toFixed(2), '-i', file, '-vn', '-af', 'volumedetect', '-f', 'null', '-'], { maxBuffer: 4 << 20 });
172
+ const m = /mean_volume:\s*(-?[\d.]+) dB/.exec(String(stderr));
173
+ return m ? Number(m[1]) : -99;
174
+ }
175
+ catch {
176
+ return -99;
177
+ }
178
+ }
package/dist/core/site.js CHANGED
@@ -25,8 +25,38 @@ catch {
25
25
  export function stripHtml(html) {
26
26
  return decode(html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
27
27
  }
28
+ /** 봇 차단(403·429·503)·빈 HTML 이면 헤드리스 크로미움으로 렌더된 DOM 을 받는다(항공·대형 쇼핑몰 · JS 전용 사이트). 크로미움이 없으면 그대로. */
29
+ async function fetchHtmlSmart(url) {
30
+ let first = null;
31
+ try {
32
+ first = await fetchHtml(url);
33
+ }
34
+ catch {
35
+ first = null;
36
+ }
37
+ const blocked = !first || [401, 403, 429, 503].includes(first.status) || first.html.length < 1500;
38
+ if (!blocked)
39
+ return { ...first, via: 'fetch' };
40
+ try {
41
+ const { chromeBin } = await import('./creatives/siteshots.js');
42
+ const bin = chromeBin();
43
+ if (!bin)
44
+ throw new Error('no chromium');
45
+ const { execFile } = await import('node:child_process');
46
+ const { promisify } = await import('node:util');
47
+ const { stdout } = await promisify(execFile)(bin, ['--headless=new', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', '--window-size=1280,2000', '--virtual-time-budget=10000', `--user-agent=${UA_BROWSER}`, '--dump-dom', url], { timeout: 60_000, maxBuffer: 32 << 20 });
48
+ const html = String(stdout);
49
+ if (html.length > 1500 && !/access denied|attention required|cf-error|captcha/i.test(html.slice(0, 4000)))
50
+ return { html, status: 200, finalUrl: first?.finalUrl || url, headers: first?.headers || new Headers(), via: 'chromium' };
51
+ }
52
+ catch { /* 폴백 실패 → 원래 결과 */ }
53
+ if (first)
54
+ return { ...first, via: 'fetch' };
55
+ throw new Error('사이트에 연결하지 못했어요');
56
+ }
57
+ const UA_BROWSER = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
28
58
  export async function readSite(url) {
29
- const { html, status, finalUrl, headers } = await fetchHtml(url);
59
+ const { html, status, finalUrl, headers } = await fetchHtmlSmart(url);
30
60
  const title = decode((html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || meta(html, 'og:title') || '').trim());
31
61
  const description = meta(html, 'description') || meta(html, 'og:description') || '';
32
62
  const lang = (html.match(/<html[^>]*\slang\s*=\s*["']?([a-zA-Z-]+)/i)?.[1] || meta(html, 'og:locale') || '').toLowerCase().slice(0, 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",