adyou 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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))
@@ -1,6 +1,8 @@
1
1
  import type { Brief, Concept, CreativeAsset, Goal } from '../state.js';
2
2
  import { type VideoJob, type VideoRatio } from './media.js';
3
3
  import { type Tone } from './playbook.js';
4
+ /** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
5
+ export declare function speechModeFor(lang: string): 'native' | 'tts';
4
6
  export type VideoAsset = CreativeAsset & {
5
7
  type: 'video';
6
8
  costKrw: number;
@@ -9,7 +9,12 @@ 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, mixVoiceover, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
13
+ import { synthesizeSpeech, ttsAvailable } from './tts.js';
14
+ import { isMockGen } from './images.js';
15
+ import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
16
+ /** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
17
+ export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
13
18
  /** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
14
19
  export async function renderEndCard(dir, brief, concept, ratio, lang, backgrounds = {}) {
15
20
  try {
@@ -25,17 +30,18 @@ export async function renderEndCard(dir, brief, concept, ratio, lang, background
25
30
  }
26
31
  }
27
32
  /** 자막 오버레이 묶음 — 훅(0~2.4초 · 상단) → 자막 줄(균등) → CTA(마지막 구간) */
28
- async function overlaysFor(dir, key, dims, text, cta, primary, durationSec, lang, person = false) {
33
+ async function overlaysFor(dir, key, dims, text, cta, primary, durationSec, lang, layout = 'top') {
29
34
  const out = [];
30
35
  const hookEnd = Math.min(2.4, durationSec * 0.3);
31
36
  // 사람 얼굴이 나오는 포맷은 훅을 상단(얼굴 위)이 아니라 가슴 아래(자막 자리)에 — 얼굴을 가리지 않게
32
37
  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);
38
+ 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 });
39
+ // 훅과 같은 문장인 자막 줄은 빼고(중복) · 훅이 자막 자리(lower)에 있으면 겹치지 않게 훅이 끝난 뒤 시작
40
+ const norm = (t) => t.replace(/[\s.,!?、。!?「」"']/g, '').toLowerCase();
41
+ const lines = text.captions.filter(Boolean).filter((t) => norm(t) !== norm(text.hook)).slice(0, 3);
35
42
  if (lines.length) {
36
- const from = text.hook ? hookEnd - 0.2 : 0;
43
+ const from = text.hook ? (layout === 'lower' ? hookEnd + 0.05 : hookEnd - 0.2) : 0;
37
44
  const seg = (durationSec - from) / lines.length;
38
- lines.forEach((t, i) => { void t; });
39
45
  for (const [i, t] of lines.entries())
40
46
  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
47
  }
@@ -73,14 +79,22 @@ export async function produceConceptVideos(o) {
73
79
  const board = c.storyboards.find((b) => b.format === format) || c.storyboards[0];
74
80
  const dims = VIDEO_DIMS[job.ratio];
75
81
  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})`);
82
+ const speaks = board.clips.some((cl) => cl.kind === 'gen' && cl.speech);
83
+ // 말하는 포맷(UGC)은 언어마다 그 언어 대사로 클립을 따로 생성(🔴 자막만 바꾸면 음성은 원어 그대로 — 2026-09-16 사장님 검수) · 말하지 않는 포맷은 원본 하나를 언어별 자막으로 공유
84
+ const rawLangs = speaks ? langs : [langs[0]];
85
+ const costByLang = {};
86
+ const rawFor = (lang) => path.join(o.dir, speaks ? `${key}_${lang}_${job.ratio}.raw.mp4` : `${key}_${job.ratio}.raw.mp4`);
87
+ for (const rl of rawLangs) {
88
+ const rawFile = rawFor(rl);
89
+ let costKrw = 0;
90
+ if (fs.existsSync(rawFile))
91
+ continue;
92
+ const lb0 = localizedBoard(board, c.i18n, rl);
93
+ 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
94
  const clipFiles = [];
81
95
  let failed = false;
82
96
  for (const [ci, clip] of board.clips.entries()) {
83
- const cf = path.join(o.dir, `${key}_${job.ratio}_c${ci}.mp4`);
97
+ const cf = path.join(o.dir, `${key}_${rl}_${job.ratio}_c${ci}.mp4`);
84
98
  try {
85
99
  if (clip.kind === 'ui_demo') {
86
100
  if (!ff)
@@ -96,13 +110,86 @@ export async function produceConceptVideos(o) {
96
110
  }
97
111
  else {
98
112
  const person = format === 'ugc_selfie';
113
+ const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
114
+ const mode = line ? speechModeFor(rl) : 'none';
99
115
  // 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 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).` : '';
101
- 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
- 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 });
116
+ const speech = line && mode === 'native' ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}): "${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).` : line ? ` The person talks to the camera with expressive face and natural hand gestures, as if sharing a genuine reaction (a voice-over will be added — do NOT render intelligible dialogue; keep mouth movement subtle). Audio: light background music only, no speech, no dialogue.` : '';
117
+ const prompt = `${clip.prompt}${speech} Style: ${genreStyle(board.genre)}. Camera: ${tw.camera}. Audio: ${job.audio ? `${board.music || tw.music}${person && mode === 'native' ? ', clear voice over the music' : ''}` : 'silent'}. No on-screen text, no subtitles, no captions, no logos, no watermark.`;
118
+ // 프레임: 참고(제품) 사진이 있으면 사진을 캔버스에 앉힌 프레임(실제 제품이 움직임) · 없으면 생성 배경 · 사람 포맷은 없음
119
+ let firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
120
+ const refImg = !person && o.refs?.[0];
121
+ if (refImg && ci === 0 && ff) {
122
+ try {
123
+ const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
124
+ firstFrame = await refCanvas(refImg, stage.bg, path.join(o.dir, `${key}_${rl}_refframe.png`), dims);
125
+ log(' 첫 프레임 = 올린 제품 사진');
126
+ }
127
+ catch { /* 생성 배경 유지 */ }
128
+ }
129
+ let r;
130
+ try {
131
+ 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 });
132
+ }
133
+ catch (e) {
134
+ if (!e.retryable)
135
+ throw e;
136
+ costKrw += Number(e.costKrw || 0);
137
+ log(` 안전 필터로 결과가 비어 와서 다른 테이크로 한 번 더 만들어요`);
138
+ 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 });
139
+ }
104
140
  costKrw += r.costKrw;
141
+ // 대사 꼬리 검사 — 마지막 0.7초가 아직 시끄러우면(≥ -27dB) 말이 끝까지 이어진 것 → 더 짧은 대사로 1회 재생성(원가 1회 추가)
142
+ if (line && ff) {
143
+ const tail = await tailLoudnessDb(cf, 0.7);
144
+ if (tail >= -27) {
145
+ log(` 대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB) → 더 짧게 한 번 더 만들어요`);
146
+ const short = line.split(/(?<=[.!?。!?])\s+/)[0] || line;
147
+ try {
148
+ 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 });
149
+ costKrw += r2.costKrw;
150
+ const t2 = await tailLoudnessDb(cf, 0.7);
151
+ log(` 다시 만든 컷 꼬리 ${t2.toFixed(0)}dB${t2 >= -27 ? ' (여전히 이어짐 · 그대로 사용)' : ' ✓'}`);
152
+ }
153
+ catch (e) {
154
+ log(` ⚠ 재생성 실패(첫 테이크 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
155
+ }
156
+ }
157
+ }
105
158
  log(` 컷 ${ci + 1}/${board.clips.length} ${clip.sec}초 ✓ 원가 ₩${Math.round(r.costKrw).toLocaleString()}`);
159
+ // QA ① 네이티브 대사: 언어·대본·끊김 검사 → 실패면 원본 소리를 낮추고 TTS 내레이션으로 대체
160
+ if (line && mode === 'native' && ff && !isMockGen()) {
161
+ const q = await checkSpeech(cf, { lang: rl, expected: line });
162
+ log(` ${q.note}`);
163
+ if (!q.ok && ttsAvailable()) {
164
+ try {
165
+ const v = await synthesizeSpeech({ text: line, lang: rl, file: path.join(o.dir, `${key}_${rl}_c${ci}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
166
+ const mixed = cf.replace(/\.mp4$/, '.vo.mp4');
167
+ await mixVoiceover(cf, v.file, mixed, { duck: 0.08 });
168
+ fs.copyFileSync(mixed, cf);
169
+ costKrw += v.costKrw;
170
+ log(' → TTS 내레이션으로 대체 ✓');
171
+ }
172
+ catch (e) {
173
+ log(` ⚠ TTS 대체 실패: ${e instanceof Error ? e.message.slice(0, 100) : e}`);
174
+ }
175
+ }
176
+ }
177
+ // QA ② 참고 제품 사진이 있으면 프레임의 제품이 같은지 → 다르면 사진 켄번즈(생성 위험 0)로 교체
178
+ if (refImg && ff && !isMockGen()) {
179
+ const q = await checkProductMatch(cf, refImg);
180
+ log(` ${q.note}`);
181
+ if (!q.ok) {
182
+ try {
183
+ const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
184
+ const canvas = await refCanvas(refImg, stage.bg, path.join(o.dir, `${key}_${rl}_refframe.png`), dims);
185
+ await kenBurnsClip(canvas, cf, { sec: clip.sec, dims });
186
+ log(' → 제품 사진 켄번즈로 교체 ✓');
187
+ }
188
+ catch (e) {
189
+ log(` ⚠ 교체 실패(생성 컷 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
190
+ }
191
+ }
192
+ }
106
193
  }
107
194
  clipFiles.push(cf);
108
195
  }
@@ -116,9 +203,10 @@ export async function produceConceptVideos(o) {
116
203
  break;
117
204
  }
118
205
  }
206
+ costByLang[rl] = costKrw;
119
207
  if (failed || !clipFiles.length) {
120
208
  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 });
209
+ 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
210
  continue;
123
211
  }
124
212
  try {
@@ -131,13 +219,45 @@ export async function produceConceptVideos(o) {
131
219
  log(` ⚠ 이어붙이기 실패(첫 컷만 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
132
220
  fs.copyFileSync(clipFiles[0], rawFile);
133
221
  }
222
+ // TTS 내레이션(영어 외 시장의 말하는 포맷) — 번역된 대사를 그 언어 음성으로 얹는다
223
+ const ttsLine = board.clips.find((cl) => cl.kind === 'gen' && cl.speech) ? (rl === board.lang ? board.clips.find((cl) => cl.speech).speech : lb0.speech || lb0.voice) : undefined;
224
+ if (ttsLine && speechModeFor(rl) === 'tts' && ff) {
225
+ try {
226
+ // 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
227
+ const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
228
+ const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
229
+ const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
230
+ const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
231
+ await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : 0.3 });
232
+ fs.copyFileSync(mixed, rawFile);
233
+ costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
234
+ log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 제거)' : ' (원본 음악 유지)'}`);
235
+ }
236
+ catch (e) {
237
+ log(` ⚠ 내레이션 실패(음악만): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
238
+ }
239
+ }
240
+ // QA ③ 프레임 위생
241
+ if (ff) {
242
+ const q = await checkFrames(rawFile);
243
+ if (!q.ok) {
244
+ log(` ❌ ${q.note} — 이 포맷은 건너뜀`);
245
+ fs.rmSync(rawFile, { force: true });
246
+ continue;
247
+ }
248
+ }
134
249
  }
135
250
  // 3) 언어 × 비율 마감 — 원본(raw)에서 비율을 먼저 만들고 그 위에 자막·엔드카드
136
- const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
137
251
  for (const lang of langs) {
252
+ const rawFile = rawFor(lang);
253
+ if (!fs.existsSync(rawFile)) {
254
+ log(` ⚠ ${lang} 원본이 없어 건너뜀`);
255
+ continue;
256
+ }
138
257
  const lb = localizedBoard(board, c.i18n, lang);
139
258
  const lc = localizedConcept(c, c.i18n, lang);
140
259
  const cta = ctaLabel(c.cta, lang);
260
+ const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
141
261
  for (const ratio of ratios) {
142
262
  const d = VIDEO_DIMS[ratio];
143
263
  const finalFile = path.join(o.dir, `${key}_${lang}_${ratio}.mp4`);
@@ -148,14 +268,18 @@ export async function produceConceptVideos(o) {
148
268
  const pr0 = await probe(rawFile);
149
269
  durationSec = pr0.durationSec;
150
270
  if (ratio !== job.ratio) {
151
- const df = path.join(o.dir, `${key}_${ratio}.raw.mp4`);
271
+ const df = path.join(o.dir, `${key}_${speaks ? lang + '_' : ''}${ratio}.raw.mp4`);
152
272
  if (!fs.existsSync(df))
153
273
  await deriveRatio(rawFile, df, ratio);
154
274
  cur = df;
155
275
  }
156
276
  if (job.captions) {
277
+ // 훅 자리: 사람 포맷은 가슴 아래 · 그 외는 첫 프레임 분석(피사체가 있는 띠를 피함 · 상단 우선)
278
+ const layout = format === 'ugc_selfie' || format === 'lifestyle' ? 'lower' : await pickHookBand(cur);
279
+ if (lang === langs[0] && ratio === job.ratio)
280
+ log(` 훅 자리: ${layout === 'top' ? '상단' : layout === 'middle' ? '중앙' : '하단'}(첫 프레임 분석)`);
157
281
  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') });
282
+ await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang, layout) });
159
283
  cur = withCap;
160
284
  }
161
285
  if (job.endCard) {
@@ -173,13 +297,14 @@ export async function produceConceptVideos(o) {
173
297
  }
174
298
  }
175
299
  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}` };
300
+ const primary = ratio === job.ratio && (speaks ? true : lang === langs[0]);
301
+ 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
302
  out.push(asset);
179
303
  await o.onAsset?.(asset);
180
304
  }
181
305
  }
182
- log(` ${format} 완성 언어 ${langs.join('·')} × 비율 ${ratios.map((r) => r.replace('x', ':')).join('·')}${costKrw ? ` · 생성 원가 ₩${Math.round(costKrw).toLocaleString()}` : ''}`);
306
+ const costKrw = Object.values(costByLang).reduce((n, v) => n + v, 0);
307
+ log(` ${format} 완성 — 언어 ${langs.join('·')}${speaks ? '(언어별 대사 클립)' : '(자막만 언어별)'} × 비율 ${[job.ratio, ...DERIVED_RATIOS[job.ratio]].map((r) => r.replace('x', ':')).join('·')}${costKrw ? ` · 생성 원가 ₩${Math.round(costKrw).toLocaleString()}` : ''}`);
183
308
  }
184
309
  return out;
185
310
  }
@@ -28,7 +28,7 @@ export function pickVideoModel(durationSec, tier, provider = 'auto', ratio = '9x
28
28
  return 'dreamina-seedance-2-5-260628';
29
29
  }
30
30
  const FORMAT_LABEL_SHORT = { ugc_selfie: '셀카 후기', ui_demo: '화면 데모', cinematic: '시네마틱', product_hero: '제품', lifestyle: '라이프스타일', text_hook: '글자 훅' };
31
- export const MODE_LABEL = { auto: '알아서(추천)', image: '이미지 위주', video: '영상 위주', text: '글·링크 위주' };
31
+ export const MODE_LABEL = { image: '이미지 + 문구(기본)', auto: '이미지 + 영상(베타)', video: '영상 위주(베타)', text: '글·링크 위주' };
32
32
  export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구사항·참고 이미지로', manual: '직접 올린 소재로' };
33
33
  /**
34
34
  * 설정 + 예산 → 실행 계획. 온라인 광고 소재의 일반 배합:
@@ -39,7 +39,8 @@ export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구
39
39
  */
40
40
  export function resolveCreativePlan(spec, ctx) {
41
41
  const s = spec || {};
42
- const mode = s.mode || 'auto';
42
+ // 기본 = 이미지 + 문구(안전) · 영상은 베타(광고주 선택) — 2026-09-16 사장님 결정
43
+ const mode = s.mode || 'image';
43
44
  const source = s.source || 'ai';
44
45
  const small = ctx.monthlyKrw < 500_000;
45
46
  const concepts = Math.max(1, Math.min(5, s.concepts || (source === 'manual' ? 1 : mode === 'text' ? 4 : 3)));
@@ -49,7 +50,7 @@ export function resolveCreativePlan(spec, ctx) {
49
50
  const ratios = ['1x1', '4x5', '9x16', '16x9'];
50
51
  const vTier = s.video?.tier || (ctx.monthlyKrw >= 2_000_000 ? 'standard' : 'economy');
51
52
  const duration = s.video?.durationSec || 8;
52
- const wantVideo = source !== 'manual' && s.video?.count !== 0 && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0) && mode !== 'text' && mode !== 'image';
53
+ const wantVideo = source !== 'manual' && s.video?.count !== 0 && mode !== 'text' && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0 || (s.video?.formats?.length || 0) > 0);
53
54
  const vRatios = s.video?.ratios?.length ? s.video.ratios : ['9x16'];
54
55
  const pb = playbookFor(ctx.industry);
55
56
  const tone = s.tone || pb.tone;
@@ -0,0 +1,5 @@
1
+ export declare function listGatewayModels(): Promise<string[]>;
2
+ /** 표(원가 있음)에 있는 모델 중 게이트웨이가 실제로 서비스하는 최신 — 표에 없으면 표의 기본을 그대로 */
3
+ export declare function newestAvailable(candidates: string[], available: string[]): string;
4
+ /** 게이트웨이 목록에 있으나 표에 없는 「더 새 버전」 — 로그로 알려 표를 갱신하게 */
5
+ export declare function newerThanTable(available: string[]): string[];
@@ -0,0 +1,35 @@
1
+ // 모델 자동 선택 — BizRouter `/v1/models` 를 실행 때 읽어(1시간 캐시) Veo·Gemini 이미지의 최신 버전을 티어별로 고른다. 조회 실패면 media.ts 의 고정표.
2
+ // 규칙: 같은 계열에서 버전 숫자가 가장 큰 것 · lite/fast/표준 접미사는 티어에 대응 · 가격표에 없는 새 모델은 원가를 모르니 「표에 있는 최신」만 쓴다(원가 추정 어긋남 방지 · 새 모델은 표 갱신으로 승격).
3
+ import { imageConfig } from './images.js';
4
+ import { IMAGE_MODELS, VIDEO_MODELS } from './media.js';
5
+ let cache = null;
6
+ export async function listGatewayModels() {
7
+ if (cache && Date.now() - cache.at < 3600_000)
8
+ return cache.ids;
9
+ const cfg = imageConfig();
10
+ if (!cfg || cfg.provider !== 'bizrouter')
11
+ return [];
12
+ try {
13
+ const r = await fetch(`${cfg.base}/v1/models`, { headers: { authorization: `Bearer ${cfg.key}` }, signal: AbortSignal.timeout(15_000) });
14
+ const j = (await r.json());
15
+ cache = { at: Date.now(), ids: (j.data || []).map((m) => m.id) };
16
+ return cache.ids;
17
+ }
18
+ catch {
19
+ return cache?.ids || [];
20
+ }
21
+ }
22
+ const ver = (id) => { const m = /(\d+(?:\.\d+)?)/.exec(id.split('/').pop() || ''); return m ? Number(m[1]) : 0; };
23
+ /** 표(원가 있음)에 있는 모델 중 게이트웨이가 실제로 서비스하는 최신 — 표에 없으면 표의 기본을 그대로 */
24
+ export function newestAvailable(candidates, available) {
25
+ const live = candidates.filter((c) => available.includes(c));
26
+ return (live.length ? live : candidates).sort((a, b) => ver(b) - ver(a))[0];
27
+ }
28
+ /** 게이트웨이 목록에 있으나 표에 없는 「더 새 버전」 — 로그로 알려 표를 갱신하게 */
29
+ export function newerThanTable(available) {
30
+ const tableVeo = Object.keys(VIDEO_MODELS).filter((m) => /veo/.test(m));
31
+ const tableImg = Object.values(IMAGE_MODELS).map((m) => m.model);
32
+ const maxVeo = Math.max(...tableVeo.map(ver));
33
+ const maxImg = Math.max(...tableImg.map(ver));
34
+ return available.filter((id) => (/google\/veo-/.test(id) && ver(id) > maxVeo) || (/google\/gemini-.*-image$/.test(id) && ver(id) > maxImg));
35
+ }
@@ -0,0 +1,22 @@
1
+ export type QaResult = {
2
+ ok: boolean;
3
+ skipped?: boolean;
4
+ note: string;
5
+ data?: Record<string, unknown>;
6
+ };
7
+ export declare const speechQaAvailable: () => boolean;
8
+ /** 대본 vs 전사 유사도 0~1 — 공백 언어는 단어 겹침 · CJK 는 글자 바이그램 겹침 */
9
+ export declare function transcriptSimilarity(expected: string, transcript: string): number;
10
+ /** 대사 검사 — 언어 일치 + 대본 유사도(≥0.35) + 끊김 아님 */
11
+ export declare function checkSpeech(video: string, o: {
12
+ lang: string;
13
+ expected: string;
14
+ }): Promise<QaResult>;
15
+ /** 제품 일치 — 참고 사진 vs 프레임 2장(1초·중간) */
16
+ export declare function checkProductMatch(video: string, refImage: string): Promise<QaResult>;
17
+ /** 프레임 위생 — 길이·크기·검은 화면 비율 */
18
+ export declare function checkFrames(video: string, minSec?: number): Promise<QaResult>;
19
+ /** 사진에 실존 인물(얼굴)이 있나 — 사이트에서 자동으로 가져온 이미지는 연예인·모델 사진일 수 있어 참고 이미지로 쓰면 초상권 위험(2026-09-16 실측: 뷰티 사이트 대표 이미지가 아이돌) → 사람 없는 제품 사진만 통과 */
20
+ export declare function containsPerson(image: string): Promise<boolean | null>;
21
+ /** 자동 수집 이미지 중 사람 없는 것만(판정 불가면 제외 — 보수적) */
22
+ export declare function filterProductRefs(files: string[], log?: (s: string) => void): Promise<string[]>;
@@ -0,0 +1,148 @@
1
+ // 생성 소재 자동 QA — 영상 크레딧을 쓴 결과물을 사람 눈 대신 기계가 먼저 본다.
2
+ // ① 대사 검사: 소리를 Gemini 2.5 Flash 에 직접 보내(BizRouter 경유는 오디오 입력 불가 · GEMINI_API_KEY) 「어느 언어 · 전사 · 끊김」 → 언어 불일치·대본과 동떨어짐이면 실패
3
+ // ② 제품 일치: 참고(제품) 사진과 영상 프레임을 qwen 비전(BizRouter · 저가)에 나란히 보여 「같은 제품인가」 → 아니면 실패
4
+ // ③ 프레임 위생: 길이 0 · 검은 화면 · 크기 이상
5
+ // 실패하면 호출자가 안전한 대체(TTS 내레이션·무음+자막·제품 사진 켄번즈)로 바꾼다. 키가 없으면 검사를 건너뛰고 skipped 로 표시(막지 않는다).
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ import { imageConfig } from './images.js';
12
+ import { sniffMime } from './render.js';
13
+ import { probe } from './videofx.js';
14
+ const execFileP = promisify(execFile);
15
+ const geminiKey = () => process.env.GEMINI_API_KEY || process.env.ADPILOT_QA_GEMINI_KEY;
16
+ export const speechQaAvailable = () => !!geminiKey();
17
+ const tokens = (t) => t.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
18
+ const bigrams = (t) => { const s = t.replace(/[^\p{L}\p{N}]/gu, ''); const out = new Set(); for (let i = 0; i < s.length - 1; i++)
19
+ out.add(s.slice(i, i + 2)); return out; };
20
+ /** 대본 vs 전사 유사도 0~1 — 공백 언어는 단어 겹침 · CJK 는 글자 바이그램 겹침 */
21
+ export function transcriptSimilarity(expected, transcript) {
22
+ const cjk = /[぀-ヿ㐀-鿿가-힯]/.test(expected);
23
+ if (cjk) {
24
+ const a = bigrams(expected), b = bigrams(transcript);
25
+ if (!a.size)
26
+ return 0;
27
+ let hit = 0;
28
+ for (const x of a)
29
+ if (b.has(x))
30
+ hit++;
31
+ return hit / a.size;
32
+ }
33
+ const a = tokens(expected), b = new Set(tokens(transcript));
34
+ if (!a.length)
35
+ return 0;
36
+ return a.filter((w) => b.has(w)).length / a.length;
37
+ }
38
+ /** 대사 검사 — 언어 일치 + 대본 유사도(≥0.35) + 끊김 아님 */
39
+ export async function checkSpeech(video, o) {
40
+ const key = geminiKey();
41
+ if (!key)
42
+ return { ok: true, skipped: true, note: '대사 검사 건너뜀(검사 키 없음)' };
43
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-qa-'));
44
+ const mp3 = path.join(dir, 'a.mp3');
45
+ try {
46
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', video, '-vn', '-ac', '1', '-ar', '16000', '-b:a', '48k', mp3]);
47
+ const b64 = fs.readFileSync(mp3).toString('base64');
48
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: 'This is the audio of a short social video ad. Return JSON only: {"lang": ISO 639-1 code of the spoken language or "none" if no speech, "transcript": verbatim speech, "cutoff": true if the speech is cut off mid-sentence at the end, "garbled": true if the speech is unintelligible or nonsensical for a native listener}.' }, { inline_data: { mime_type: 'audio/mp3', data: b64 } }] }], generationConfig: { responseMimeType: 'application/json' } }), signal: AbortSignal.timeout(90_000) });
49
+ const j = (await r.json());
50
+ const txt = j.candidates?.[0]?.content?.parts?.[0]?.text || '{}';
51
+ const d = JSON.parse(txt.replace(/```json|```/g, ''));
52
+ const lang = String(d.lang || 'none').slice(0, 2).toLowerCase();
53
+ const want = o.lang.split('-')[0].toLowerCase();
54
+ const sim = transcriptSimilarity(o.expected, d.transcript || '');
55
+ const cutoff = d.cutoff === true || d.cutoff === 'yes' || d.cutoff === 'true';
56
+ const garbled = d.garbled === true || d.garbled === 'yes' || d.garbled === 'true';
57
+ const problems = [];
58
+ if (lang === 'none')
59
+ problems.push('말소리 없음');
60
+ else if (lang !== want)
61
+ problems.push(`언어 불일치(${lang}≠${want})`);
62
+ if (sim < 0.35)
63
+ problems.push(`대본과 다름(${Math.round(sim * 100)}%)`);
64
+ if (garbled)
65
+ problems.push('발음 불명확');
66
+ if (cutoff)
67
+ problems.push('끝에서 끊김');
68
+ return { ok: !problems.length, note: problems.length ? `대사 검사 실패: ${problems.join(' · ')} — 들린 말 「${(d.transcript || '').slice(0, 60)}」` : `대사 검사 ✓ (${lang} · 대본 일치 ${Math.round(sim * 100)}%)`, data: { lang, transcript: d.transcript, sim, cutoff, garbled } };
69
+ }
70
+ catch (e) {
71
+ return { ok: true, skipped: true, note: `대사 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 80) : e})` };
72
+ }
73
+ finally {
74
+ fs.rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ }
77
+ const dataUrl = (file) => { const b = fs.readFileSync(file); return `data:${sniffMime(b)};base64,${b.toString('base64')}`; };
78
+ /** 제품 일치 — 참고 사진 vs 프레임 2장(1초·중간) */
79
+ export async function checkProductMatch(video, refImage) {
80
+ const cfg = imageConfig();
81
+ if (!cfg || cfg.provider !== 'bizrouter')
82
+ return { ok: true, skipped: true, note: '제품 검사 건너뜀(키 없음)' };
83
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-qa-'));
84
+ try {
85
+ const p = await probe(video);
86
+ const frames = [];
87
+ for (const t of [1, Math.max(1.5, p.durationSec / 2)]) {
88
+ const f = path.join(dir, `f${frames.length}.jpg`);
89
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(2), '-i', video, '-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '4', f]);
90
+ frames.push(f);
91
+ }
92
+ const res = await fetch(`${cfg.base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'bizrouter/qwen-3.8-flash', max_tokens: 200, messages: [{ role: 'user', content: [{ type: 'text', text: 'Image 1 is the advertiser\'s real product photo (reference). Images 2-3 are frames from a generated ad video. Is the product shown in the frames the SAME product as the reference — same overall shape, packaging type, and dominant colors (label text may differ slightly)? Answer JSON only: {"same": true|false, "confidence": 0-1, "reason": "<10 words"}' }, { type: 'image_url', image_url: { url: dataUrl(refImage) } }, ...frames.map((f) => ({ type: 'image_url', image_url: { url: dataUrl(f) } }))] }] }), signal: AbortSignal.timeout(90_000) });
93
+ const j = (await res.json());
94
+ const txt = j.choices?.[0]?.message?.content || '{}';
95
+ const m = /\{[\s\S]*\}/.exec(txt);
96
+ const d = JSON.parse(m ? m[0] : '{}');
97
+ const ok = d.same === true && (d.confidence ?? 1) >= 0.5;
98
+ return { ok, note: ok ? `제품 일치 ✓ (${Math.round((d.confidence ?? 1) * 100)}%)` : `제품 불일치: ${d.reason || '참고 사진과 다른 제품'}`, data: d };
99
+ }
100
+ catch (e) {
101
+ return { ok: true, skipped: true, note: `제품 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 80) : e})` };
102
+ }
103
+ finally {
104
+ fs.rmSync(dir, { recursive: true, force: true });
105
+ }
106
+ }
107
+ /** 프레임 위생 — 길이·크기·검은 화면 비율 */
108
+ export async function checkFrames(video, minSec = 2) {
109
+ try {
110
+ const p = await probe(video);
111
+ if (p.durationSec < minSec || !p.w || !p.h)
112
+ return { ok: false, note: `영상 이상(길이 ${p.durationSec.toFixed(1)}초 · ${p.w}×${p.h})` };
113
+ const { stderr } = await execFileP('ffmpeg', ['-v', 'info', '-i', video, '-vf', 'blackdetect=d=0.5:pix_th=0.10', '-an', '-f', 'null', '-'], { maxBuffer: 4 << 20 });
114
+ const black = [...String(stderr).matchAll(/black_duration:([\d.]+)/g)].reduce((n, m) => n + Number(m[1]), 0);
115
+ if (black > p.durationSec * 0.4)
116
+ return { ok: false, note: `검은 화면 ${black.toFixed(1)}초` };
117
+ return { ok: true, note: '프레임 ✓' };
118
+ }
119
+ catch (e) {
120
+ return { ok: true, skipped: true, note: `프레임 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 60) : e})` };
121
+ }
122
+ }
123
+ /** 사진에 실존 인물(얼굴)이 있나 — 사이트에서 자동으로 가져온 이미지는 연예인·모델 사진일 수 있어 참고 이미지로 쓰면 초상권 위험(2026-09-16 실측: 뷰티 사이트 대표 이미지가 아이돌) → 사람 없는 제품 사진만 통과 */
124
+ export async function containsPerson(image) {
125
+ const cfg = imageConfig();
126
+ if (!cfg || cfg.provider !== 'bizrouter')
127
+ return null;
128
+ try {
129
+ const res = await fetch(`${cfg.base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'bizrouter/qwen-3.8-flash', max_tokens: 20, messages: [{ role: 'user', content: [{ type: 'text', text: 'Does this image contain a real human person or face (photo or realistic render)? Answer exactly "yes" or "no".' }, { type: 'image_url', image_url: { url: dataUrl(image) } }] }] }), signal: AbortSignal.timeout(60_000) });
130
+ const j = (await res.json());
131
+ return /yes/i.test(j.choices?.[0]?.message?.content || '');
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ /** 자동 수집 이미지 중 사람 없는 것만(판정 불가면 제외 — 보수적) */
138
+ export async function filterProductRefs(files, log) {
139
+ const out = [];
140
+ for (const f of files) {
141
+ const p = await containsPerson(f);
142
+ if (p === false)
143
+ out.push(f);
144
+ else
145
+ log?.(` 사이트 이미지 ${f.split('/').pop()} 은 사람이 있어 참고에서 제외(초상권)`);
146
+ }
147
+ return out;
148
+ }
@@ -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) {
@@ -118,9 +120,12 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
118
120
  const sub = dark ? 'rgba(255,255,255,.82)' : 'rgba(11,21,38,.72)';
119
121
  const bgStyle = bg ? {} : { backgroundImage: dark ? `linear-gradient(135deg, ${brief.palette.dark} 0%, ${hexA(primary, 0.55)} 100%)` : `linear-gradient(135deg, ${brief.palette.light} 0%, ${hexA(primary, 0.18)} 100%)` };
120
122
  // 크기별 글자 크기
121
- const headFs = short ? Math.round(hh * 0.34) : narrow ? Math.round(w * 0.14) : banner ? Math.round(base * 0.12) : Math.round(base * (hh > w ? 0.085 : 0.078));
122
- const bodyFs = short ? 0 : narrow ? Math.round(w * 0.085) : banner ? Math.round(base * 0.062) : Math.round(base * 0.034);
123
- const ctaFs = short ? Math.round(hh * 0.28) : narrow ? Math.round(w * 0.09) : banner ? Math.round(base * 0.06) : Math.round(base * 0.028);
123
+ // 글자 크기 메타 포스터(피드·스토리) 화면에서 읽히게 크게(짧은 변의 11~12% · 메타는 텍스트 20% 규칙이 없음 · 2021 폐지)
124
+ // 구글 디스플레이 이미지는 반대로 글자를 적게(구글이 헤드라인·설명을 따로 얹고, 이미지 텍스트 20% 초과는 비승인 위험) 헤드라인만 작게
125
+ const googleLight = size.medium === 'google' && !banner;
126
+ const headFs = short ? Math.round(hh * 0.34) : narrow ? Math.round(w * 0.14) : banner ? Math.round(base * 0.12) : googleLight ? Math.round(base * 0.062) : Math.round(base * (hh > w ? 0.12 : 0.105));
127
+ const bodyFs = short ? 0 : narrow ? Math.round(w * 0.085) : banner ? Math.round(base * 0.062) : googleLight ? 0 : Math.round(base * 0.046);
128
+ const ctaFs = short ? Math.round(hh * 0.28) : narrow ? Math.round(w * 0.09) : banner ? Math.round(base * 0.06) : googleLight ? Math.round(base * 0.034) : Math.round(base * 0.04);
124
129
  const children = [];
125
130
  if (bg)
126
131
  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 }));
@@ -128,7 +133,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
128
133
  const brandRow = h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [
129
134
  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
135
  ]);
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);
136
+ 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
137
  if (short) {
133
138
  // 가로 띠: 로고 | 헤드라인 | CTA
134
139
  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]));
@@ -142,7 +147,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
142
147
  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', {}, '')]),
143
148
  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 }, [
144
149
  h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5, wordBreak: 'keep-all' }, head),
145
- banner && bodyFs < 14 ? h('div', {}, '') : h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45, wordBreak: 'keep-all' }, banner ? body.slice(0, 70) : body),
150
+ (banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45, wordBreak: 'keep-all' }, banner ? body.slice(0, 70) : body),
146
151
  h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
147
152
  ]),
148
153
  banner ? h('div', {}, '') : h('div', { display: 'flex', justifyContent: 'space-between', fontSize: Math.round(headFs * 0.28), fontWeight: 500, color: sub }, [h('div', {}, brief.company), h('div', {}, domain)]),
@@ -202,15 +207,17 @@ export async function renderOverlay(file, w, hh, text, o) {
202
207
  const base = Math.min(w, hh);
203
208
  const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
204
209
  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)
210
+ ? 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
211
  : 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
212
  // 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
208
213
  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);
214
+ 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
215
  const el = hook
211
216
  ? (o.layout === 'lower'
212
217
  ? 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]))
218
+ : o.layout === 'middle'
219
+ ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: FONT_FAMILY }, [hookEl])
220
+ : h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), fontFamily: FONT_FAMILY }, [hookEl]))
214
221
  : 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
222
  const svg = await satori(el, { width: w, height: hh, fonts });
216
223
  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,19 +118,36 @@ 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
+ // 키 정규화 — 모델이 "ja (Japanese)"·"Japanese"·"JA" 로 돌려주기도 함
126
+ const norm = (k) => k.trim().toLowerCase().split(/[\s(]/)[0];
127
+ const byKey = {};
128
+ for (const [k, v] of Object.entries(r.translations)) {
129
+ byKey[norm(k)] = v;
130
+ const nm = Object.entries(LANG_NAME).find(([, name]) => name.toLowerCase() === k.trim().toLowerCase());
131
+ if (nm)
132
+ byKey[nm[0].toLowerCase()] = v;
133
+ }
125
134
  for (const l of targets) {
126
- const t = r.translations[l] || r.translations[l.split('-')[0]];
127
- if (!t)
128
- continue;
135
+ let t = byKey[l.toLowerCase()] || byKey[l.split('-')[0].toLowerCase()];
136
+ if (!t) {
137
+ // 한 언어만 다시(누락 보정) · 그래도 없으면 원문으로 채워 소재 생성은 막지 않는다
138
+ try {
139
+ const one = await completeJson({ schema: 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: 3000, timeoutMs: 120_000, system: `광고 문구를 ${langName(l)}(${l}) 로 자연스럽게 옮긴다. 뜻·사실·숫자 유지 · 브랜드명 유지 · 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자 · 자막 각 18자(영문 32자) · 대사는 4~5초 한 문장. 출력 JSON {headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice,speech}]}`, user: JSON.stringify({ 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 })) }) });
140
+ t = one;
141
+ }
142
+ catch {
143
+ t = { headlines: concept.headlines, bodies: concept.bodies, descriptions: concept.descriptions, boards: [] };
144
+ }
145
+ }
129
146
  out[l] = { headlines: t.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean), bodies: t.bodies.map((b) => b.trim()).filter(Boolean), descriptions: t.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean) };
130
147
  for (const b of boards) {
131
148
  const tb = t.boards?.find((x) => x.id === b.id);
132
149
  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 } };
150
+ 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
151
  }
135
152
  }
136
153
  return out;
@@ -138,7 +155,7 @@ export async function translateCopy(brief, concept, boards, langs) {
138
155
  /** 언어별 콘티 자막/훅 꺼내기(없으면 원문) */
139
156
  export function localizedBoard(b, i18n, lang) {
140
157
  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 };
158
+ 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
159
  }
143
160
  /** 언어별 컨셉(문구 치환) — 이미지 렌더·매체 카피에 */
144
161
  export function localizedConcept(c, i18n, lang) {
@@ -0,0 +1,14 @@
1
+ export type TtsResult = {
2
+ file: string;
3
+ durationSec: number;
4
+ costKrw: number;
5
+ };
6
+ export declare function ttsAvailable(): boolean;
7
+ /** 한 문장 내레이션 mp3 — 톤은 광고 UGC(캐주얼·자연스럽게). 실패는 예외. 원가는 응답에 없어 추정(≈₩15/문장). */
8
+ export declare function synthesizeSpeech(o: {
9
+ text: string;
10
+ lang: string;
11
+ file: string;
12
+ tone?: 'calm' | 'lively' | 'bold';
13
+ persona?: string;
14
+ }): Promise<TtsResult>;
@@ -0,0 +1,21 @@
1
+ // 내레이션 TTS — BizRouter `/v1/audio/speech`(openai/gpt-4o-mini-tts · 한국어 자연도 실측 10/10). 영어가 아닌 시장에서 「말하는 포맷」의 음성은 Veo 발화 대신 TTS 를 얹는다(Veo 한국어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수).
2
+ import fs from 'node:fs';
3
+ import { imageConfig } from './images.js';
4
+ import { probe } from './videofx.js';
5
+ const VOICE = { ko: 'nova', ja: 'shimmer', en: 'nova', zh: 'nova', default: 'alloy' };
6
+ export function ttsAvailable() { const c = imageConfig(); return !!c && c.provider === 'bizrouter'; }
7
+ /** 한 문장 내레이션 mp3 — 톤은 광고 UGC(캐주얼·자연스럽게). 실패는 예외. 원가는 응답에 없어 추정(≈₩15/문장). */
8
+ export async function synthesizeSpeech(o) {
9
+ const cfg = imageConfig();
10
+ if (!cfg || cfg.provider !== 'bizrouter')
11
+ throw Object.assign(new Error('TTS 키(BizRouter)가 없어요'), { fatal: true });
12
+ const lang = o.lang.split('-')[0];
13
+ const style = o.tone === 'calm' ? 'warm, calm, trustworthy' : o.tone === 'bold' ? 'energetic, confident, punchy' : 'casual, upbeat, friendly';
14
+ const instructions = `Speak in natural ${lang === 'ko' ? 'Korean' : lang === 'ja' ? 'Japanese' : lang === 'zh' ? 'Chinese' : 'the given language'} like a real person talking to their phone camera in a short social video ad: ${style}, conversational pace, not read from a script.${o.persona ? ` Speaker: ${o.persona}.` : ''}`;
15
+ const res = await fetch(`${cfg.base}/v1/audio/speech`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'openai/gpt-4o-mini-tts', input: o.text, voice: VOICE[lang] || VOICE.default, instructions, response_format: 'mp3' }), signal: AbortSignal.timeout(60_000) });
16
+ if (!res.ok)
17
+ throw new Error(`TTS ${res.status} ${(await res.text()).slice(0, 160)}`);
18
+ fs.writeFileSync(o.file, Buffer.from(await res.arrayBuffer()));
19
+ const p = await probe(o.file).catch(() => ({ durationSec: 0 }));
20
+ return { file: o.file, durationSec: p.durationSec, costKrw: 15 };
21
+ }
@@ -34,7 +34,8 @@ async function mockVideo(i) {
34
34
  let n = 0;
35
35
  for (const ch of i.prompt)
36
36
  n = (n * 31 + ch.charCodeAt(0)) >>> 0;
37
- const color = `0x${(n & 0xffffff).toString(16).padStart(6, '0')}`;
37
+ // 모의 색은 어둡지 않게(QA 검은 화면 검사에 걸리지 않도록) — 각 채널 96~255
38
+ const color = `0x${[16, 8, 0].map((sh) => (96 + ((n >> sh) & 0xff) % 160).toString(16).padStart(2, '0')).join('')}`;
38
39
  const inputs = i.firstFrame && fs.existsSync(i.firstFrame)
39
40
  ? ['-loop', '1', '-t', String(i.durationSec), '-i', i.firstFrame, '-f', 'lavfi', '-t', String(i.durationSec), '-i', 'anullsrc=r=44100:cl=stereo', '-vf', `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},format=yuv420p`]
40
41
  : ['-f', 'lavfi', '-t', String(i.durationSec), '-i', `color=c=${color}:s=${w}x${h}:r=24`, '-f', 'lavfi', '-t', String(i.durationSec), '-i', 'anullsrc=r=44100:cl=stereo', '-vf', 'format=yuv420p'];
@@ -67,8 +68,9 @@ async function veo(i, cfg) {
67
68
  const s = (await st.json().catch(() => ({})));
68
69
  if (s.cost_krw)
69
70
  costKrw = s.cost_krw;
71
+ // 🔴 Veo 「No video in response」 = 안전 필터(실사 인물·식품 등)로 결과가 비어 옴 · 접수비는 발생 → 호출자가 다른 테이크로 1회 재시도(retryable)
70
72
  if (s.status === 'failed')
71
- throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw });
73
+ throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw, retryable: /No video in response|filtered|safety/i.test(String(s.error || '')) });
72
74
  if (s.status === 'completed') {
73
75
  const dl = await fetch(`${cfg.base}/v1/video/generation/${acc.operation_id}/download`, { headers: H, signal: AbortSignal.timeout(300_000) });
74
76
  if (!dl.ok)
@@ -57,3 +57,20 @@ 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>;
67
+ /** 내레이션 얹기 — 원본 소리는 낮추고(duck) 0.4초 뒤부터 TTS · 원본이 무음이면 TTS 만 */
68
+ export declare function mixVoiceover(video: string, voice: string, output: string, o?: {
69
+ duck?: number;
70
+ startSec?: number;
71
+ }): Promise<void>;
72
+ /** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
73
+ export declare function refCanvas(ref: string, bgPng: string, output: string, dims: {
74
+ w: number;
75
+ h: number;
76
+ }): Promise<string>;
@@ -134,3 +134,59 @@ 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
+ }
179
+ /** 내레이션 얹기 — 원본 소리는 낮추고(duck) 0.4초 뒤부터 TTS · 원본이 무음이면 TTS 만 */
180
+ export async function mixVoiceover(video, voice, output, o = {}) {
181
+ const p = await probe(video);
182
+ const duck = o.duck ?? 0.18;
183
+ const st = Math.round((o.startSec ?? 0.4) * 1000);
184
+ const fc = p.hasAudio ? `[0:a]volume=${duck}[bg];[1:a]adelay=${st}|${st},volume=1.0[vo];[bg][vo]amix=inputs=2:duration=first:dropout_transition=0,alimiter=limit=0.95[a]` : `[1:a]adelay=${st}|${st},apad,atrim=0:${p.durationSec.toFixed(2)}[a]`;
185
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', video, '-i', voice, '-filter_complex', fc, '-map', '0:v', '-map', '[a]', '-c:v', 'copy', '-c:a', 'aac', '-ar', '48000', '-ac', '2', '-shortest', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
186
+ }
187
+ /** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
188
+ export async function refCanvas(ref, bgPng, output, dims) {
189
+ const w = Math.round(dims.w * 0.82);
190
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', bgPng, '-i', ref, '-filter_complex', `[1:v]scale=${w}:-1:force_original_aspect_ratio=decrease,scale='min(iw,${w})':'min(ih,${Math.round(dims.h * 0.7)})':force_original_aspect_ratio=decrease[p];[0:v]scale=${dims.w}:${dims.h}[b];[b][p]overlay=(W-w)/2:(H-h)/2,format=rgb24`, '-frames:v', '1', output]);
191
+ return output;
192
+ }
package/dist/core/ops.js CHANGED
@@ -8,6 +8,7 @@ import { generateBackgrounds, userBackgrounds } from './creatives/images.js';
8
8
  import { chargeFor, resolveCreativePlan } from './creatives/media.js';
9
9
  import { produceConceptVideos } from './creatives/factory.js';
10
10
  import { downloadSiteImages, screenshotSite } from './creatives/siteshots.js';
11
+ import { filterProductRefs } from './creatives/qa.js';
11
12
  import { renderConcept, renderLogo } from './creatives/render.js';
12
13
  import { SIZES } from './creatives/specs.js';
13
14
  import { aiAvailable, completeJson } from './llm.js';
@@ -91,8 +92,8 @@ export async function opCreatives(p, o) {
91
92
  let siteImages = [];
92
93
  if (cplan.videos.perConcept.length) {
93
94
  siteShot = await screenshotSite(p.url, path.join(dir, 'site_shot.png'));
94
- siteImages = await downloadSiteImages(p.brief.siteImages || [], dir, 3);
95
- log(`사이트 화면 ${siteShot ? '캡처 ✓' : '캡처 불가(크로미움 없음)'} · 사이트 이미지 ${siteImages.length}장`);
95
+ siteImages = await filterProductRefs(await downloadSiteImages(p.brief.siteImages || [], dir, 4), log);
96
+ log(`사이트 화면 ${siteShot ? '캡처 ✓' : '캡처 불가(크로미움 없음)'} · 사이트 이미지(사람 없는 것) ${siteImages.length}장`);
96
97
  }
97
98
  const langs = [...new Set(p.budget.markets.map((m) => m.language))];
98
99
  p.concepts.forEach((c, i) => { void i; });
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/dist/index.d.ts CHANGED
@@ -14,6 +14,9 @@ export * from './core/creatives/factory.js';
14
14
  export * from './core/creatives/playbook.js';
15
15
  export * from './core/creatives/storyboard.js';
16
16
  export * from './core/creatives/siteshots.js';
17
+ export * from './core/creatives/tts.js';
18
+ export * from './core/creatives/qa.js';
19
+ export * from './core/creatives/models.js';
17
20
  export { computePlan, type PlanInput } from './core/plan.js';
18
21
  export * from './core/optimize.js';
19
22
  export * from './core/report.js';
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ export * from './core/creatives/factory.js';
15
15
  export * from './core/creatives/playbook.js';
16
16
  export * from './core/creatives/storyboard.js';
17
17
  export * from './core/creatives/siteshots.js';
18
+ export * from './core/creatives/tts.js';
19
+ export * from './core/creatives/qa.js';
20
+ export * from './core/creatives/models.js';
18
21
  export { computePlan } from './core/plan.js';
19
22
  export * from './core/optimize.js';
20
23
  export * from './core/report.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",