adyou 0.6.2 → 0.6.3

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.
@@ -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, mixVoiceover, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
12
+ import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, mixVoiceover, motionScore, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
13
13
  import { synthesizeSpeech, ttsAvailable } from './tts.js';
14
14
  import { isMockGen } from './images.js';
15
15
  import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
@@ -74,6 +74,17 @@ export async function produceConceptVideos(o) {
74
74
  log(` ${extra.map(langName).join('·')} 로 문구·자막 옮기는 중…`);
75
75
  c.i18n = { ...(c.i18n || {}), ...(await translateCopy(o.brief, c, c.storyboards, extra)) };
76
76
  }
77
+ // 제품 사진(광고주 업로드 또는 실물 제품으로 분류된 사이트 이미지)이 없으면 「제품 히어로」는 상상 제품이 되므로 라이프스타일로 대체(콘티도 그 포맷으로)
78
+ const hasProductPhoto = !!(o.refs && o.refs.length);
79
+ if (!hasProductPhoto && o.jobs.some((j) => j.format === 'product_hero')) {
80
+ for (const j of o.jobs)
81
+ if (j.format === 'product_hero')
82
+ j.format = o.jobs.some((x) => x.format === 'lifestyle') ? 'cinematic' : 'lifestyle';
83
+ log(' 제품 사진이 없어 「제품 히어로」 대신 장면 포맷(라이프스타일/시네마틱)으로 바꿨어요');
84
+ const need = [...new Set(o.jobs.map((j) => j.format || 'cinematic'))];
85
+ if (need.some((f) => !c.storyboards.some((b) => b.format === f)))
86
+ c.storyboards = await planStoryboards(o.brief, c, { formats: need, durationSec: o.jobs[0]?.durationSec || 8, tone, goal: o.goal || 'traffic', hasScreens, log });
87
+ }
77
88
  for (const job of o.jobs) {
78
89
  const format = (job.format || 'cinematic');
79
90
  const board = c.storyboards.find((b) => b.format === format) || c.storyboards[0];
@@ -115,16 +126,16 @@ export async function produceConceptVideos(o) {
115
126
  // 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
116
127
  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 does NOT speak at all — lips stay closed or in a natural smile; they react to the camera (nod, raised eyebrows, showing the product or their phone screen, a small laugh) like a silent vlog moment; a narrator voice-over will be added later. Audio: light background music only, no speech, no dialogue, no humming.` : '';
117
128
  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];
129
+ // 첫 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 프레임으로. 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
130
+ let firstFrame;
131
+ const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
121
132
  if (refImg && ci === 0 && ff) {
122
133
  try {
123
134
  const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
124
135
  firstFrame = await refCanvas(refImg, stage.bg, path.join(o.dir, `${key}_${rl}_refframe.png`), dims);
125
- log(' 첫 프레임 = 올린 제품 사진');
136
+ log(` 첫 프레임 = 제품 사진(${path.basename(refImg)})`);
126
137
  }
127
- catch { /* 생성 배경 유지 */ }
138
+ catch { /* 없이 진행 */ }
128
139
  }
129
140
  // 말하는 클립의 모델 — 영어 아닌 언어는 Seedance 네이티브 발화(립싱크) · 영어는 Veo
130
141
  const clipModel = line && mode === 'native' ? speechVideoModel(rl, job.model) : job.model;
@@ -178,6 +189,21 @@ export async function produceConceptVideos(o) {
178
189
  }
179
190
  }
180
191
  }
192
+ // QA ②-0 움직임: 사실상 정지 화면이면 첫 프레임 없이 「역동적 카메라」로 1회 재생성(원가 추가)
193
+ if (ff && !isMockGen()) {
194
+ const mo = await motionScore(cf);
195
+ if (mo < 3) {
196
+ log(` 움직임이 거의 없어요(점수 ${mo.toFixed(1)}) → 장면을 다시 만들어요`);
197
+ try {
198
+ const r3 = await generateVideo({ file: cf, prompt: `${prompt} IMPORTANT: continuous visible motion throughout — moving subject, camera push-in or pan, changing light; never a static image.`, ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, negative: 'static image, still frame, slideshow, text, subtitles, watermark, logo', log });
199
+ costKrw += r3.costKrw;
200
+ log(` 다시 만든 컷 움직임 ${(await motionScore(cf)).toFixed(1)}`);
201
+ }
202
+ catch (e) {
203
+ log(` ⚠ 재생성 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
204
+ }
205
+ }
206
+ }
181
207
  // QA ② 참고 제품 사진이 있으면 프레임의 제품이 같은지 → 다르면 사진 켄번즈(생성 위험 0)로 교체
182
208
  if (refImg && ff && !isMockGen()) {
183
209
  const q = await checkProductMatch(cf, refImg);
@@ -223,16 +249,17 @@ export async function produceConceptVideos(o) {
223
249
  log(` ⚠ 이어붙이기 실패(첫 컷만 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
224
250
  fs.copyFileSync(clipFiles[0], rawFile);
225
251
  }
226
- // TTS 내레이션(영어 시장의 말하는 포맷) 번역된 대사를 언어 음성으로 얹는다
227
- 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;
228
- if (ttsLine && speechModeFor(rl) === 'tts' && ff) {
252
+ // 내레이션 — ① 말하는 포맷인데 TTS 모드(Seedance 없음) 사람 없는 포맷(제품·시네마틱·라이프스타일·글자 훅) 콘티 voice TTS 로(무음·음악만인 영상 방지 · 2026-09-17)
253
+ const speakingBoard = board.clips.some((cl) => cl.kind === 'gen' && cl.speech);
254
+ const ttsLine = speakingBoard ? (speechModeFor(rl) === 'tts' ? (rl === board.lang ? board.clips.find((cl) => cl.speech).speech : lb0.speech || lb0.voice) : undefined) : (lb0.voice || board.voice);
255
+ if (ttsLine && ttsAvailable() && ff && !isMockGen()) {
229
256
  try {
230
257
  // 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
231
258
  const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
232
259
  const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
233
260
  const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
234
261
  const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
235
- await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : 0.3 });
262
+ await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : speakingBoard ? 0.3 : 0.4 });
236
263
  fs.copyFileSync(mixed, rawFile);
237
264
  costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
238
265
  log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 제거)' : ' (원본 음악 유지)'}`);
@@ -55,7 +55,8 @@ export const GENRE_STYLE = {
55
55
  anime: 'high-quality Japanese anime style, clean linework, vivid cel shading, dramatic sky and light, studio-quality animation',
56
56
  '3d': 'stylized 3D render, soft global illumination, Pixar-like character appeal, clean materials',
57
57
  illustration: 'flat editorial illustration, bold shapes, limited palette, tasteful texture',
58
- motion_graphics: 'kinetic typography and motion graphics, clean vector shapes, brand colors, smooth easing',
58
+ // 🔴 AI 그리는 글자·숫자는 깨진다(SaaS 글자 훅에 의미 없는 숫자 · 2026-09-17) → 모션그래픽은 추상 도형만, 글자는 우리 자막이 담당
59
+ motion_graphics: 'abstract motion graphics with clean vector shapes, flowing lines and brand-color gradients, smooth easing — strictly NO letters, NO numbers, NO words, NO UI, NO charts with labels',
59
60
  product_shot: 'studio product photography, macro detail, controlled lighting, clean backdrop',
60
61
  };
61
62
  export const genreStyle = (g) => GENRE_STYLE[g || ''] || GENRE_STYLE.photoreal;
@@ -16,7 +16,12 @@ export declare function checkSpeech(video: string, o: {
16
16
  export declare function checkProductMatch(video: string, refImage: string): Promise<QaResult>;
17
17
  /** 프레임 위생 — 길이·크기·검은 화면 비율 */
18
18
  export declare function checkFrames(video: string, minSec?: number): Promise<QaResult>;
19
- /** 사진에 실존 인물(얼굴)이 있나사이트에서 자동으로 가져온 이미지는 연예인·모델 사진일 있어 참고 이미지로 쓰면 초상권 위험(2026-09-16 실측: 뷰티 사이트 대표 이미지가 아이돌) 사람 없는 제품 사진만 통과 */
19
+ /** 사이트 자동 수집 이미지 분류 실물 제품 사진만 참고로 쓴다. 사람(초상권) · 로고/배너/글자 그래픽(2026-09-16 실사고: og:image 빨간 로고 상자가 프레임이 됨) 제외 */
20
+ export declare function classifyRefImage(image: string): Promise<{
21
+ person: boolean;
22
+ product: boolean;
23
+ graphic: boolean;
24
+ } | null>;
20
25
  export declare function containsPerson(image: string): Promise<boolean | null>;
21
- /** 자동 수집 이미지 중 사람 없는 것만(판정 불가면 제외 — 보수적) */
26
+ /** 자동 수집 이미지 중 「사람 없음 · 실물 제품 · 그래픽 아님」만(판정 불가면 제외 — 보수적) */
22
27
  export declare function filterProductRefs(files: string[], log?: (s: string) => void): Promise<string[]>;
@@ -120,29 +120,39 @@ export async function checkFrames(video, minSec = 2) {
120
120
  return { ok: true, skipped: true, note: `프레임 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 60) : e})` };
121
121
  }
122
122
  }
123
- /** 사진에 실존 인물(얼굴)이 있나사이트에서 자동으로 가져온 이미지는 연예인·모델 사진일 있어 참고 이미지로 쓰면 초상권 위험(2026-09-16 실측: 뷰티 사이트 대표 이미지가 아이돌) 사람 없는 제품 사진만 통과 */
124
- export async function containsPerson(image) {
123
+ /** 사이트 자동 수집 이미지 분류 실물 제품 사진만 참고로 쓴다. 사람(초상권) · 로고/배너/글자 그래픽(2026-09-16 실사고: og:image 빨간 로고 상자가 프레임이 됨) 제외 */
124
+ export async function classifyRefImage(image) {
125
125
  const cfg = imageConfig();
126
126
  if (!cfg || cfg.provider !== 'bizrouter')
127
127
  return null;
128
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) });
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: 80, messages: [{ role: 'user', content: [{ type: 'text', text: 'Classify this image for use as an ad reference. Answer JSON only: {"person": true if a real human person/face is visible, "product": true if a physical product/item (packaging, device, food, garment, vehicle, building, etc.) is clearly visible as the main subject, "graphic": true if it is mainly a logo, wordmark, text banner, icon, screenshot or flat brand graphic}' }, { type: 'image_url', image_url: { url: dataUrl(image) } }] }] }), signal: AbortSignal.timeout(60_000) });
130
130
  const j = (await res.json());
131
- return /yes/i.test(j.choices?.[0]?.message?.content || '');
131
+ const m = /\{[\s\S]*\}/.exec(j.choices?.[0]?.message?.content || '');
132
+ const d = JSON.parse(m ? m[0] : '{}');
133
+ return { person: !!d.person, product: !!d.product, graphic: !!d.graphic };
132
134
  }
133
135
  catch {
134
136
  return null;
135
137
  }
136
138
  }
137
- /** 자동 수집 이미지 사람 없는 것만(판정 불가면 제외 보수적) */
139
+ export async function containsPerson(image) { const c = await classifyRefImage(image); return c ? c.person : null; }
140
+ /** 자동 수집 이미지 중 「사람 없음 · 실물 제품 · 그래픽 아님」만(판정 불가면 제외 — 보수적) */
138
141
  export async function filterProductRefs(files, log) {
139
142
  const out = [];
140
143
  for (const f of files) {
141
- const p = await containsPerson(f);
142
- if (p === false)
143
- out.push(f);
144
+ const c = await classifyRefImage(f);
145
+ const name = f.split('/').pop();
146
+ if (!c) {
147
+ log?.(` 사이트 이미지 ${name} 판정 불가 → 제외`);
148
+ continue;
149
+ }
150
+ if (c.person)
151
+ log?.(` 사이트 이미지 ${name} 은 사람이 있어 제외(초상권)`);
152
+ else if (c.graphic || !c.product)
153
+ log?.(` 사이트 이미지 ${name} 은 로고·배너·그래픽이라 제외`);
144
154
  else
145
- log?.(` 사이트 이미지 ${f.split('/').pop()} 은 사람이 있어 참고에서 제외(초상권)`);
155
+ out.push(f);
146
156
  }
147
157
  return out;
148
158
  }
@@ -17,6 +17,8 @@ type El = {
17
17
  };
18
18
  /** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
19
19
  export declare function onColor(hex: string): string;
20
+ /** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
21
+ export declare function wordsEl(text: string, style: Record<string, unknown>, align?: 'left' | 'center'): El;
20
22
  /** 한 규격의 트리 */
21
23
  export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
22
24
  export declare const CTA_LABEL: Record<string, Record<string, string>>;
@@ -97,6 +97,12 @@ async function fetchLogo(url, dir) {
97
97
  const h = (type, style, children, extra = {}) => ({ type, props: { style, ...extra, ...(children === undefined ? {} : { children }) } });
98
98
  /** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
99
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'; }
100
+ /** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
101
+ export function wordsEl(text, style, align = 'left') {
102
+ const words = String(text || '').split(/\s+/).filter(Boolean);
103
+ const gap = Math.round(Number(style.fontSize || 16) * 0.26);
104
+ return h('div', { display: 'flex', flexWrap: 'wrap', justifyContent: align === 'center' ? 'center' : 'flex-start', columnGap: gap, rowGap: 0, ...style, whiteSpace: 'nowrap' }, words.map((w) => h('span', { whiteSpace: 'nowrap' }, w)));
105
+ }
100
106
  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})`; }
101
107
  /** 한 규격의 트리 */
102
108
  export function tree(size, concept, brief, bg, logo, ctaText) {
@@ -140,15 +146,15 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
140
146
  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]));
141
147
  }
142
148
  else if (narrow) {
143
- children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.15, letterSpacing: -0.8, wordBreak: 'keep-all' }, head), h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4, wordBreak: 'keep-all' }, body.slice(0, 60))]), ctaPill]));
149
+ children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [wordsEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.15, letterSpacing: -0.8 }), wordsEl(body.slice(0, 60), { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4 })]), ctaPill]));
144
150
  }
145
151
  else {
146
152
  const isWide = w / hh > 1.6;
147
153
  children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [
148
154
  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', {}, '')]),
149
155
  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 }, [
150
- h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5, wordBreak: 'keep-all' }, head),
151
- (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) : googleLight ? body.slice(0, 48) : body),
156
+ wordsEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }),
157
+ (banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(banner ? body.slice(0, 70) : googleLight ? body.slice(0, 48) : body, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }),
152
158
  h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
153
159
  ]),
154
160
  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)]),
@@ -209,10 +215,10 @@ export async function renderOverlay(file, w, hh, text, o) {
209
215
  const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
210
216
  const pill = o.kind === 'cta'
211
217
  ? 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)
212
- : 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);
218
+ : h('div', { display: 'flex', backgroundColor: 'rgba(11,21,38,.62)', borderRadius: Math.round(fs1 * 0.4), padding: `${Math.round(fs1 * 0.35)}px ${Math.round(fs1 * 0.6)}px`, maxWidth: Math.round(w * 0.86) }, [wordsEl(text, { fontSize: fs1, fontWeight: 800, color: '#fff', lineHeight: 1.25, letterSpacing: -1 }, 'center')]);
213
219
  // 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
214
220
  const hook = o.kind === 'hook';
215
- 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);
221
+ const hookEl = h('div', { display: 'flex', maxWidth: Math.round(w * 0.86), backgroundColor: hexA(o.primary, 0.92), padding: `${Math.round(base * 0.035)}px ${Math.round(base * 0.05)}px`, borderRadius: Math.round(base * 0.03), transform: 'rotate(-2deg)' }, [wordsEl(text, { fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)' }, 'center')]);
216
222
  const el = hook
217
223
  ? (o.layout === 'lower'
218
224
  ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
@@ -59,6 +59,7 @@ export async function generateStoryboards(brief, concept, o) {
59
59
  - clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
60
60
  - ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 한 문장(영어 ≤ 12단어 · 한국어/일본어 ≤ 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다(뚝 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
61
61
  - ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
62
+ - text_hook 포맷의 clips[].prompt 는 「자막 뒤에 깔릴 배경 장면」이다 — 타겟이 일하는/사용하는 실사 장면(사람 뒷모습·손·사무실·제품 사용) 또는 추상 도형. 화면 안에 글자·숫자·차트 라벨·UI 를 그리지 말 것(우리 자막이 글자를 담당 · AI 글자는 깨진다).
62
63
  - 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
63
64
  - 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
64
65
  user: JSON.stringify({ brief: { company: brief.company, offer: brief.offer, category: brief.category, industry: brief.industry, audience: brief.audience, audienceProfile: brief.audienceProfile, usp: brief.usp, proofPoints: brief.proofPoints, tone: brief.tone, language: lang, palette: brief.palette }, concept: { key: concept.key, name: concept.name, angle: concept.angle, headline: concept.headlines[0], body: concept.bodies[0], imagePrompt: concept.imagePrompt }, goal: o.goal, cta, durationSec: o.durationSec, formats: o.formats }),
@@ -74,3 +74,5 @@ export declare function refCanvas(ref: string, bgPng: string, output: string, di
74
74
  w: number;
75
75
  h: number;
76
76
  }): Promise<string>;
77
+ /** 움직임 점수 — 1초·중간·끝 프레임(작게) 사이 평균 픽셀 차. 0~255 · 3 미만이면 사실상 정지 화면(「이미지 하나 붙여놓은 영상」) */
78
+ export declare function motionScore(video: string): Promise<number>;
@@ -190,3 +190,18 @@ export async function refCanvas(ref, bgPng, output, dims) {
190
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
191
  return output;
192
192
  }
193
+ /** 움직임 점수 — 1초·중간·끝 프레임(작게) 사이 평균 픽셀 차. 0~255 · 3 미만이면 사실상 정지 화면(「이미지 하나 붙여놓은 영상」) */
194
+ export async function motionScore(video) {
195
+ try {
196
+ const p = await probe(video);
197
+ const W = 48, H = 84;
198
+ const grab = async (t) => { const { stdout } = await execFileP('ffmpeg', ['-v', 'error', '-ss', t.toFixed(2), '-i', video, '-frames:v', '1', '-vf', `scale=${W}:${H}`, '-pix_fmt', 'gray', '-f', 'rawvideo', '-'], { encoding: 'buffer', maxBuffer: 1 << 20 }); return stdout; };
199
+ const a = await grab(Math.min(0.8, p.durationSec * 0.1)), b = await grab(p.durationSec * 0.5), c = await grab(Math.max(0.5, p.durationSec - 0.6));
200
+ const diff = (x, y) => { let d = 0; const n = Math.min(x.length, y.length); for (let i = 0; i < n; i++)
201
+ d += Math.abs(x[i] - y[i]); return n ? d / n : 0; };
202
+ return Math.max(diff(a, b), diff(b, c));
203
+ }
204
+ catch {
205
+ return 99;
206
+ }
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",