adyou 0.6.2 → 0.6.4

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,10 +9,10 @@ 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, lastFrame, 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
- import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
15
+ import { checkArtifacts, checkFrames, checkProductMatch, checkSpeech } from './qa.js';
16
16
  /** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
17
17
  export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || hasSeedance() || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
18
18
  /** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
@@ -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];
@@ -114,17 +125,26 @@ export async function produceConceptVideos(o) {
114
125
  const mode = line ? speechModeFor(rl) : 'none';
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
- 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];
128
+ const castLine = board.cast ? ` Character (identical in every shot same face, hair, skin tone, outfit): ${board.cast}.` : '';
129
+ const prompt = `${clip.prompt}${castLine}${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.`;
130
+ // 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) — 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 첫 프레임으로. 그 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
131
+ let firstFrame;
132
+ const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
121
133
  if (refImg && ci === 0 && ff) {
122
134
  try {
123
135
  const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
124
136
  firstFrame = await refCanvas(refImg, stage.bg, path.join(o.dir, `${key}_${rl}_refframe.png`), dims);
125
- log(' 첫 프레임 = 올린 제품 사진');
137
+ log(` 첫 프레임 = 제품 사진(${path.basename(refImg)})`);
138
+ }
139
+ catch { /* 없이 진행 */ }
140
+ }
141
+ // 인물·장소가 이어지는 컷은 앞 컷의 마지막 프레임에서 시작(컷 사이 인물 바뀜 방지 · 2026-09-17 에어서울 실사고)
142
+ if (!firstFrame && clip.continues && ci > 0 && ff && clipFiles[ci - 1] && fs.existsSync(clipFiles[ci - 1])) {
143
+ try {
144
+ firstFrame = await lastFrame(clipFiles[ci - 1], path.join(o.dir, `${key}_${rl}_c${ci}_from.png`));
145
+ log(' 앞 컷 마지막 프레임에서 이어서');
126
146
  }
127
- catch { /* 생성 배경 유지 */ }
147
+ catch { /* 없이 진행 */ }
128
148
  }
129
149
  // 말하는 클립의 모델 — 영어 아닌 언어는 Seedance 네이티브 발화(립싱크) · 영어는 Veo
130
150
  const clipModel = line && mode === 'native' ? speechVideoModel(rl, job.model) : job.model;
@@ -178,6 +198,37 @@ export async function produceConceptVideos(o) {
178
198
  }
179
199
  }
180
200
  }
201
+ // QA ①-b 비주얼 결함(중복 물체·손가락·얼굴 왜곡·불가능한 제품 형태) → 1회 재생성
202
+ if (ff && !isMockGen()) {
203
+ const art = await checkArtifacts(cf);
204
+ log(` ${art.note}`);
205
+ if (!art.ok) {
206
+ try {
207
+ const r4 = await generateVideo({ file: cf, prompt: `${prompt} Physically plausible and clean: exactly one of each object, correct anatomy (five fingers), no duplicated or overlapping items, no melting or warped shapes.`, ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, firstFrame, negative: 'duplicated objects, double image, extra fingers, extra limbs, deformed hands, warped face, melting, floating parts, text, subtitles, watermark, logo', log });
208
+ costKrw += r4.costKrw;
209
+ const art2 = await checkArtifacts(cf);
210
+ log(` 다시 만든 컷 ${art2.note}`);
211
+ }
212
+ catch (e) {
213
+ log(` ⚠ 재생성 실패(첫 테이크 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
214
+ }
215
+ }
216
+ }
217
+ // QA ②-0 움직임: 사실상 정지 화면이면 첫 프레임 없이 「역동적 카메라」로 1회 재생성(원가 추가)
218
+ if (ff && !isMockGen()) {
219
+ const mo = await motionScore(cf);
220
+ if (mo < 3) {
221
+ log(` 움직임이 거의 없어요(점수 ${mo.toFixed(1)}) → 장면을 다시 만들어요`);
222
+ try {
223
+ 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 });
224
+ costKrw += r3.costKrw;
225
+ log(` 다시 만든 컷 움직임 ${(await motionScore(cf)).toFixed(1)}`);
226
+ }
227
+ catch (e) {
228
+ log(` ⚠ 재생성 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
229
+ }
230
+ }
231
+ }
181
232
  // QA ② 참고 제품 사진이 있으면 프레임의 제품이 같은지 → 다르면 사진 켄번즈(생성 위험 0)로 교체
182
233
  if (refImg && ff && !isMockGen()) {
183
234
  const q = await checkProductMatch(cf, refImg);
@@ -223,16 +274,17 @@ export async function produceConceptVideos(o) {
223
274
  log(` ⚠ 이어붙이기 실패(첫 컷만 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
224
275
  fs.copyFileSync(clipFiles[0], rawFile);
225
276
  }
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) {
277
+ // 내레이션 — ① 말하는 포맷인데 TTS 모드(Seedance 없음) 사람 없는 포맷(제품·시네마틱·라이프스타일·글자 훅) 콘티 voice TTS 로(무음·음악만인 영상 방지 · 2026-09-17)
278
+ const speakingBoard = board.clips.some((cl) => cl.kind === 'gen' && cl.speech);
279
+ 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);
280
+ if (ttsLine && ttsAvailable() && ff && !isMockGen()) {
229
281
  try {
230
282
  // 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
231
283
  const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
232
284
  const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
233
285
  const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
234
286
  const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
235
- await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : 0.3 });
287
+ await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : speakingBoard ? 0.3 : 0.4 });
236
288
  fs.copyFileSync(mixed, rawFile);
237
289
  costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
238
290
  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,14 @@ 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[]>;
28
+ /** 비주얼 결함 — 3프레임을 qwen 비전에: 중복/이중 물체 · 손가락·팔 이상 · 얼굴 왜곡 · 물리적으로 불가능한 제품(쿠션에서 마스카라 브러시) · 깨진 글자. 실패면 재생성 대상 */
29
+ export declare function checkArtifacts(video: string): Promise<QaResult>;
@@ -120,29 +120,67 @@ 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
  }
159
+ /** 비주얼 결함 — 3프레임을 qwen 비전에: 중복/이중 물체 · 손가락·팔 이상 · 얼굴 왜곡 · 물리적으로 불가능한 제품(쿠션에서 마스카라 브러시) · 깨진 글자. 실패면 재생성 대상 */
160
+ export async function checkArtifacts(video) {
161
+ const cfg = imageConfig();
162
+ if (!cfg || cfg.provider !== 'bizrouter')
163
+ return { ok: true, skipped: true, note: '결함 검사 건너뜀(키 없음)' };
164
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-qa-'));
165
+ try {
166
+ const p = await probe(video);
167
+ const frames = [];
168
+ for (const t of [0.6, p.durationSec / 2, Math.max(0.8, p.durationSec - 0.6)]) {
169
+ const f = path.join(dir, `f${frames.length}.jpg`);
170
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(2), '-i', video, '-frames:v', '1', '-vf', 'scale=540:-1', '-q:v', '4', f]);
171
+ frames.push(f);
172
+ }
173
+ 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: 220, messages: [{ role: 'user', content: [{ type: 'text', text: 'These are 3 frames from an AI-generated short ad. Look for visual defects a viewer would notice: duplicated or doubled objects (e.g. two overlapping products/wands), extra or malformed fingers/limbs, warped or melting faces, physically impossible product forms (a mascara wand coming out of a compact, a bottle merging into a hand), floating parts, garbled letters. Ignore intentional stylization. Answer JSON only: {"defects": true|false, "severity": 0-3, "issues": ["<8 words each"]}' }, ...frames.map((f) => ({ type: 'image_url', image_url: { url: dataUrl(f) } }))] }] }), signal: AbortSignal.timeout(90_000) });
174
+ const j = (await res.json());
175
+ const m = /\{[\s\S]*\}/.exec(j.choices?.[0]?.message?.content || '');
176
+ const d = JSON.parse(m ? m[0] : '{}');
177
+ const bad = d.defects === true && (d.severity ?? 3) >= 2;
178
+ return { ok: !bad, note: bad ? `비주얼 결함: ${(d.issues || []).slice(0, 3).join(' · ')}` : `비주얼 검사 ✓${d.defects ? ` (경미: ${(d.issues || [])[0] || ''})` : ''}`, data: d };
179
+ }
180
+ catch (e) {
181
+ return { ok: true, skipped: true, note: `결함 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 60) : e})` };
182
+ }
183
+ finally {
184
+ fs.rmSync(dir, { recursive: true, force: true });
185
+ }
186
+ }
@@ -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])
@@ -4,7 +4,8 @@ export type Clip = {
4
4
  sec: number;
5
5
  kind: 'gen' | 'ui_demo';
6
6
  prompt: string;
7
- speech?: string;
7
+ speech?: string; /** 앞 컷과 같은 인물·장소가 이어짐 → 앞 컷 마지막 프레임을 첫 프레임으로 */
8
+ continues?: boolean;
8
9
  };
9
10
  export type Storyboard = {
10
11
  id: string;
@@ -20,6 +21,8 @@ export type Storyboard = {
20
21
  score?: number;
21
22
  scoreNote?: string;
22
23
  lang: string;
24
+ /** 인물 외형 고정 문장(영어 · 모든 컷 프롬프트에 붙임 · 컷 사이 인물 바뀜 방지) */
25
+ cast?: string;
23
26
  };
24
27
  export type LocalizedBoard = {
25
28
  hook: string;
@@ -7,7 +7,8 @@ import { FORMAT_DESC, genreStyle, playbookFor, TONE_WORDS } from './playbook.js'
7
7
  import { fit, GOOGLE_LIMITS, policyIssues, wlen } from './specs.js';
8
8
  const BoardSchema = z.object({
9
9
  format: z.string(), genre: z.string().optional(), hook: z.string(), captions: z.array(z.string()).min(1).max(5), voice: z.string().optional().nullable(), music: z.string().optional(),
10
- clips: z.array(z.object({ sec: z.number(), kind: z.enum(['gen', 'ui_demo']).default('gen'), prompt: z.string(), speech: z.string().optional().nullable() })).min(1).max(3),
10
+ clips: z.array(z.object({ sec: z.number(), kind: z.enum(['gen', 'ui_demo']).default('gen'), prompt: z.string(), speech: z.string().optional().nullable(), continues: z.boolean().optional().nullable() })).min(1).max(3),
11
+ cast: z.string().optional().nullable(),
11
12
  });
12
13
  const LANG_NAME = { ko: 'Korean', en: 'English', ja: 'Japanese', 'zh-TW': 'Traditional Chinese', zh: 'Chinese', de: 'German', fr: 'French', es: 'Spanish', vi: 'Vietnamese', th: 'Thai', id: 'Indonesian', pt: 'Portuguese', it: 'Italian', nl: 'Dutch' };
13
14
  export const langName = (l) => LANG_NAME[l] || LANG_NAME[l.split('-')[0]] || l;
@@ -59,8 +60,11 @@ export async function generateStoryboards(brief, concept, o) {
59
60
  - clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
60
61
  - ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 한 문장(영어 ≤ 12단어 · 한국어/일본어 ≤ 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다(뚝 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
61
62
  - ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
63
+ - text_hook 포맷의 clips[].prompt 는 「자막 뒤에 깔릴 배경 장면」이다 — 타겟이 일하는/사용하는 실사 장면(사람 뒷모습·손·사무실·제품 사용) 또는 추상 도형. 화면 안에 글자·숫자·차트 라벨·UI 를 그리지 말 것(우리 자막이 글자를 담당 · AI 글자는 깨진다).
64
+ - 사람이 나오는 board 는 cast 에 인물 외형 고정 문장(영어 1문장 · 성별/나이대/인종·피부톤/머리/옷/소품 · 브리프 타겟에 맞게)을 쓰고, 그 인물이 이어지는 컷은 clips[].continues=true(앞 컷의 마지막 프레임에서 이어짐 · 인물·장소 동일). 인물 없는 컷은 continues=false.
65
+ - 제품은 물리적으로 말이 되게 — 실제 제품 종류 하나만(쿠션에서 마스카라 브러시가 나오는 식 금지) · 한 컷에 한 제품 · 손과 제품 비율 자연스럽게.
62
66
  - 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
63
- - 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
67
+ - 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,cast,clips:[{sec,kind,prompt,speech,continues}]}]}`,
64
68
  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 }),
65
69
  });
66
70
  const boards = [];
@@ -70,8 +74,8 @@ export async function generateStoryboards(brief, concept, o) {
70
74
  boards.push(fallbackBoard(brief, concept, p.format, p.clips, genres[0], cta, lang, o.durationSec));
71
75
  continue;
72
76
  }
73
- const clips = p.clips.map((pc, i) => { const src = b.clips[i] || b.clips[b.clips.length - 1]; return { sec: pc.sec, kind: pc.kind, prompt: (src?.prompt || '').trim(), speech: src?.speech || undefined }; });
74
- boards.push({ id: `${concept.key}_${p.format}`, format: p.format, genre: p.format === 'ugc_selfie' ? 'ugc' : (b.genre && genres.includes(b.genre) ? b.genre : genres[0]) || 'photoreal', hook: fitHook(b.hook), captions: b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3), voice: b.voice || undefined, music: b.music || tone.music, cta, durationSec: o.durationSec, clips, lang });
77
+ const clips = p.clips.map((pc, i) => { const src = b.clips[i] || b.clips[b.clips.length - 1]; return { sec: pc.sec, kind: pc.kind, prompt: (src?.prompt || '').trim(), speech: src?.speech || undefined, continues: i > 0 ? !!src?.continues : false }; });
78
+ boards.push({ id: `${concept.key}_${p.format}`, format: p.format, genre: p.format === 'ugc_selfie' ? 'ugc' : (b.genre && genres.includes(b.genre) ? b.genre : genres[0]) || 'photoreal', hook: fitHook(b.hook), captions: b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3), voice: b.voice || undefined, music: b.music || tone.music, cta, durationSec: o.durationSec, clips, lang, cast: b.cast || undefined });
75
79
  }
76
80
  return boards;
77
81
  }
@@ -74,3 +74,7 @@ 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>;
79
+ /** 마지막 프레임 PNG — 다음 컷 첫 프레임(인물·장소 연속) */
80
+ export declare function lastFrame(video: string, output: string): Promise<string>;
@@ -190,3 +190,23 @@ 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
+ }
208
+ /** 마지막 프레임 PNG — 다음 컷 첫 프레임(인물·장소 연속) */
209
+ export async function lastFrame(video, output) {
210
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.15', '-i', video, '-frames:v', '1', '-update', '1', output]);
211
+ return output;
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",