adyou 0.5.1 → 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,17 +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, person = false) {
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
32
  if (text.hook)
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: person ? 'lower' : 'top' }), start: 0, end: hookEnd });
34
- 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);
35
37
  if (lines.length) {
36
- const from = text.hook ? hookEnd - 0.2 : 0;
38
+ const from = text.hook ? (layout === 'lower' ? hookEnd + 0.05 : hookEnd - 0.2) : 0;
37
39
  const seg = (durationSec - from) / lines.length;
38
- lines.forEach((t, i) => { void t; });
39
40
  for (const [i, t] of lines.entries())
40
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) });
41
42
  }
@@ -73,14 +74,22 @@ export async function produceConceptVideos(o) {
73
74
  const board = c.storyboards.find((b) => b.format === format) || c.storyboards[0];
74
75
  const dims = VIDEO_DIMS[job.ratio];
75
76
  const key = `${c.key}_${format}`;
76
- const rawFile = path.join(o.dir, `${key}_${job.ratio}.raw.mp4`);
77
- let costKrw = 0;
78
- if (!fs.existsSync(rawFile)) {
79
- 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})`);
80
89
  const clipFiles = [];
81
90
  let failed = false;
82
91
  for (const [ci, clip] of board.clips.entries()) {
83
- 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`);
84
93
  try {
85
94
  if (clip.kind === 'ui_demo') {
86
95
  if (!ff)
@@ -96,12 +105,40 @@ export async function produceConceptVideos(o) {
96
105
  }
97
106
  else {
98
107
  const person = format === 'ugc_selfie';
108
+ const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
99
109
  // 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
100
- const speech = clip.speech ? ` The person speaks to camera in ${langName(board.lang)}: "${clip.speech}". Natural lip sync, conversational, genuine. 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).` : '';
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).` : '';
101
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.`;
102
112
  const firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
103
- 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
+ }
104
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
+ }
105
142
  log(` 컷 ${ci + 1}/${board.clips.length} ${clip.sec}초 ✓ 원가 ₩${Math.round(r.costKrw).toLocaleString()}`);
106
143
  }
107
144
  clipFiles.push(cf);
@@ -116,9 +153,10 @@ export async function produceConceptVideos(o) {
116
153
  break;
117
154
  }
118
155
  }
156
+ costByLang[rl] = costKrw;
119
157
  if (failed || !clipFiles.length) {
120
158
  if (costKrw > 0)
121
- 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 });
122
160
  continue;
123
161
  }
124
162
  try {
@@ -133,11 +171,16 @@ export async function produceConceptVideos(o) {
133
171
  }
134
172
  }
135
173
  // 3) 언어 × 비율 마감 — 원본(raw)에서 비율을 먼저 만들고 그 위에 자막·엔드카드
136
- const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
137
174
  for (const lang of langs) {
175
+ const rawFile = rawFor(lang);
176
+ if (!fs.existsSync(rawFile)) {
177
+ log(` ⚠ ${lang} 원본이 없어 건너뜀`);
178
+ continue;
179
+ }
138
180
  const lb = localizedBoard(board, c.i18n, lang);
139
181
  const lc = localizedConcept(c, c.i18n, lang);
140
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)) : [])];
141
184
  for (const ratio of ratios) {
142
185
  const d = VIDEO_DIMS[ratio];
143
186
  const finalFile = path.join(o.dir, `${key}_${lang}_${ratio}.mp4`);
@@ -148,14 +191,18 @@ export async function produceConceptVideos(o) {
148
191
  const pr0 = await probe(rawFile);
149
192
  durationSec = pr0.durationSec;
150
193
  if (ratio !== job.ratio) {
151
- const df = path.join(o.dir, `${key}_${ratio}.raw.mp4`);
194
+ const df = path.join(o.dir, `${key}_${speaks ? lang + '_' : ''}${ratio}.raw.mp4`);
152
195
  if (!fs.existsSync(df))
153
196
  await deriveRatio(rawFile, df, ratio);
154
197
  cur = df;
155
198
  }
156
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' ? '중앙' : '하단'}(첫 프레임 분석)`);
157
204
  const withCap = path.join(o.dir, `${key}_${lang}_${ratio}.cap.mp4`);
158
- await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang, format === 'ugc_selfie' || format === 'lifestyle') });
205
+ await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang, layout) });
159
206
  cur = withCap;
160
207
  }
161
208
  if (job.endCard) {
@@ -173,13 +220,14 @@ export async function produceConceptVideos(o) {
173
220
  }
174
221
  }
175
222
  fs.copyFileSync(cur, finalFile);
176
- const primary = lang === langs[0] && ratio === job.ratio;
177
- 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}` };
178
225
  out.push(asset);
179
226
  await o.onAsset?.(asset);
180
227
  }
181
228
  }
182
- 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()}` : ''}`);
183
231
  }
184
232
  return out;
185
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>>;
@@ -32,7 +34,7 @@ export declare function renderOverlay(file: string, w: number, hh: number, text:
32
34
  kind: 'line' | 'cta' | 'hook';
33
35
  primary: string;
34
36
  lang?: string; /** 훅 위치 — top(기본) · lower(사람 얼굴이 나오는 포맷: 가슴 아래 · 자막 자리) */
35
- layout?: 'top' | 'lower';
37
+ layout?: 'top' | 'middle' | 'lower';
36
38
  }): Promise<string>;
37
39
  /** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
38
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,15 +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
213
  ? (o.layout === 'lower'
212
214
  ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
213
- : h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), 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]))
214
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]);
215
219
  const svg = await satori(el, { width: w, height: hh, fonts });
216
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[];
@@ -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단어 이내 · 대사(voice)는 말하면 4~5초 안에 끝나는 짧은 한 문장(영어 ≤ 12단어 · 일본어 ≤ 22자). 브랜드명(${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)
@@ -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>;
@@ -134,3 +134,45 @@ export async function kenBurnsClip(image, output, o) {
134
134
  const frames = Math.round(o.sec * fps);
135
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 });
136
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.1",
3
+ "version": "0.5.2",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",