adyou 0.6.3 → 0.6.5

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,14 +5,14 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { DERIVED_RATIOS, VIDEO_DIMS, hasSeedance, speechVideoModel } from './media.js';
8
- import { genreStyle, TONE_WORDS } from './playbook.js';
8
+ import { genreStyle, PACE, SCENE_FORMATS, 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, motionScore, 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 — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
@@ -58,6 +58,7 @@ export async function produceConceptVideos(o) {
58
58
  log(' ⚠ ffmpeg 가 없어 자막·엔드카드·비율 파생 없이 원본 영상만 써요');
59
59
  const tone = o.tone || 'lively';
60
60
  const tw = TONE_WORDS[tone];
61
+ const pace = PACE[tone];
61
62
  const baseLang = o.brief.language || 'ko';
62
63
  // 시장 언어만 만든다(브리프 언어가 시장에 없으면 번역 원문으로만 쓰고 그 언어 영상은 만들지 않음)
63
64
  const langs = o.langs?.length ? [...new Set(o.langs)] : [baseLang];
@@ -124,8 +125,12 @@ export async function produceConceptVideos(o) {
124
125
  const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
125
126
  const mode = line ? speechModeFor(rl) : 'none';
126
127
  // 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
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.` : '';
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.`;
128
+ // 2026-09-17 사장님 「말이 너무 느리다 · 빨리 치면 대사 들어가도 된다」 신난 친구 톤으로 빠르게 · 마지막 1초만 무언
129
+ const speech = line && mode === 'native' ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}), talking QUICKLY and excitedly like a friend sharing great news — fast natural pace, no long pauses, every word clear: "${line}". Natural lip sync, genuine, native-speaker accent. They start speaking immediately at second 0 and finish the whole line by second ${Math.max(4, clip.sec - 1)}; for the final second they are 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.` : '';
130
+ const castLine = board.cast ? ` Character (identical in every shot — same face, hair, skin tone, outfit): ${board.cast}.` : '';
131
+ // 장면 컷(말하지 않는)은 빠른 템포 명시 — 느긋한 풍경·정지 인물 금지(이어붙일 때 ${pace.speed}배속도 함)
132
+ const paceLine = !line && SCENE_FORMATS.includes(format) ? ` Pacing: fast-paced and energetic — visible action within the first half second, two beats of motion inside the shot (action, then a snap change), never a slow drift over scenery or a person standing still.` : '';
133
+ const prompt = `${clip.prompt}${castLine}${speech}${paceLine} Style: ${genreStyle(board.genre)}. Camera: ${tw.camera}. Audio: ${job.audio ? `${board.music || tw.music}${person && mode === 'native' ? ', clear voice over the music' : ''}${!line ? ', whoosh and impact sound effects on the action beats' : ''}` : 'silent'}. No on-screen text, no subtitles, no captions, no logos, no watermark.`;
129
134
  // 첫 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) — 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 첫 프레임으로. 그 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
130
135
  let firstFrame;
131
136
  const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
@@ -137,13 +142,23 @@ export async function produceConceptVideos(o) {
137
142
  }
138
143
  catch { /* 없이 진행 */ }
139
144
  }
145
+ // 인물·장소가 이어지는 컷은 앞 컷의 마지막 프레임에서 시작(컷 사이 인물 바뀜 방지 · 2026-09-17 에어서울 실사고)
146
+ if (!firstFrame && clip.continues && ci > 0 && ff && clipFiles[ci - 1] && fs.existsSync(clipFiles[ci - 1])) {
147
+ try {
148
+ firstFrame = await lastFrame(clipFiles[ci - 1], path.join(o.dir, `${key}_${rl}_c${ci}_from.png`));
149
+ log(' 앞 컷 마지막 프레임에서 이어서');
150
+ }
151
+ catch { /* 없이 진행 */ }
152
+ }
140
153
  // 말하는 클립의 모델 — 영어 아닌 언어는 Seedance 네이티브 발화(립싱크) · 영어는 Veo
141
154
  const clipModel = line && mode === 'native' ? speechVideoModel(rl, job.model) : job.model;
142
155
  if (clipModel !== job.model)
143
156
  log(` ${langName(rl)} 대사는 Seedance 2.5 네이티브 발화로(립싱크)`);
144
157
  let r;
158
+ // 참고 사진은 실사 장르에만 — 애니·3D·일러스트·모션그래픽 컷에 사진(특히 로고)을 참고로 넣으면 세계관 대신 로고가 흐른다(ISEKAI'D 실사고 · 2026-09-17)
159
+ const photoGenre = !['anime', '3d', 'illustration', 'motion_graphics'].includes(board.genre);
145
160
  try {
146
- r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model: clipModel, 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 });
161
+ r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, firstFrame, refs: !firstFrame && !person && photoGenre ? o.refs?.slice(0, 3) : undefined, negative: 'text, subtitles, captions, letters, watermark, logo, blurry, distorted face, extra fingers', log });
147
162
  }
148
163
  catch (e) {
149
164
  if (!e.retryable)
@@ -189,6 +204,22 @@ export async function produceConceptVideos(o) {
189
204
  }
190
205
  }
191
206
  }
207
+ // QA ①-b 비주얼 결함(중복 물체·손가락·얼굴 왜곡·불가능한 제품 형태) → 1회 재생성
208
+ if (ff && !isMockGen()) {
209
+ const art = await checkArtifacts(cf);
210
+ log(` ${art.note}`);
211
+ if (!art.ok) {
212
+ try {
213
+ 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 });
214
+ costKrw += r4.costKrw;
215
+ const art2 = await checkArtifacts(cf);
216
+ log(` 다시 만든 컷 ${art2.note}`);
217
+ }
218
+ catch (e) {
219
+ log(` ⚠ 재생성 실패(첫 테이크 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
220
+ }
221
+ }
222
+ }
192
223
  // QA ②-0 움직임: 사실상 정지 화면이면 첫 프레임 없이 「역동적 카메라」로 1회 재생성(원가 추가)
193
224
  if (ff && !isMockGen()) {
194
225
  const mo = await motionScore(cf);
@@ -239,9 +270,14 @@ export async function produceConceptVideos(o) {
239
270
  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 });
240
271
  continue;
241
272
  }
273
+ // 말하지 않는 장면 포맷은 톤 템포만큼 배속해 붙인다(Veo 는 느긋 · 1.25~1.35배가 「보통」으로 보임 · 말하는 컷은 원속도)
274
+ const speed = speaks || !SCENE_FORMATS.includes(format) ? 1 : pace.speed;
242
275
  try {
243
- if (ff)
244
- await concatClips(clipFiles, rawFile, dims);
276
+ if (ff) {
277
+ await concatClips(clipFiles, rawFile, dims, { speed });
278
+ if (speed !== 1)
279
+ log(` ${clipFiles.length}컷 하드컷 이어붙임 · ${speed}배속(빠른 템포)`);
280
+ }
245
281
  else
246
282
  fs.copyFileSync(clipFiles[0], rawFile);
247
283
  }
@@ -257,12 +293,13 @@ export async function produceConceptVideos(o) {
257
293
  // 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
258
294
  const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
259
295
  const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
260
- const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
296
+ const rawSec = (await probe(rawFile).catch(() => ({ durationSec: job.durationSec }))).durationSec;
297
+ const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona, fitSec: Math.max(3, rawSec - 0.6) });
261
298
  const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
262
- await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : speakingBoard ? 0.3 : 0.4 });
299
+ await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0.28 : speakingBoard ? 0.45 : 0.6 });
263
300
  fs.copyFileSync(mixed, rawFile);
264
301
  costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
265
- log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 제거)' : ' (원본 음악 유지)'}`);
302
+ log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 크게 낮춤 · 말하는 동안 사이드체인)' : ' (원본 음악 유지 · 말하는 동안만 낮춤)'}`);
266
303
  }
267
304
  catch (e) {
268
305
  log(` ⚠ 내레이션 실패(음악만): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
@@ -1,4 +1,4 @@
1
- import { pickFormats, playbookFor } from './playbook.js';
1
+ import { pickFormats, playbookFor, sceneCuts } from './playbook.js';
2
2
  /** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
3
3
  export const CREATIVE_MARKUP = 1.1;
4
4
  export const chargeFor = (costKrw) => Math.ceil(Math.max(0, costKrw) * CREATIVE_MARKUP);
@@ -70,7 +70,9 @@ export function resolveCreativePlan(spec, ctx) {
70
70
  const m = VIDEO_MODELS[model];
71
71
  const sec = Math.min(duration, m.maxSec);
72
72
  // ui_demo 는 8초 중 4초만 생성(나머지는 실제 화면 · 무료)
73
- const genSec = formats[i] === 'ui_demo' && sec <= 8 && (ctx.hasScreens ?? true) ? Math.round(sec / 2) : sec;
73
+ // 장면 포맷(시네마틱·라이프스타일·글자 훅)은 템포에 따라 4초 2~3개를 생성(빠른 템포 · 2026-09-17) 생성 = × 4
74
+ const cuts = sceneCuts(formats[i], sec, tone);
75
+ const genSec = formats[i] === 'ui_demo' && sec <= 8 && (ctx.hasScreens ?? true) ? Math.round(sec / 2) : cuts > 1 ? cuts * 4 : sec;
74
76
  // 말하는 포맷: 영어 아닌 시장 언어가 있으면 그 언어 클립은 Seedance 원가로(언어별 클립 · 언어 수만큼)
75
77
  const langs = ctx.langs?.length ? ctx.langs : ['ko'];
76
78
  const speaking = SPEAKING_FORMATS.includes(formats[i]);
@@ -90,7 +92,7 @@ export function resolveCreativePlan(spec, ctx) {
90
92
  else
91
93
  lines.push('이미지 생성 없이 브랜드 색 배경 + 문구로 만들어요(비용 0)');
92
94
  if (conceptsWithVideo)
93
- lines.push(`영상 ${conceptsWithVideo * perConcept.length}편(방향 ${conceptsWithVideo} × 포맷 ${perConcept.map((v) => `${FORMAT_LABEL_SHORT[v.format || 'cinematic']} ${v.durationSec}초`).join('·')}) ≈ ₩${videosKrw.toLocaleString()}${perConcept.some((v) => v.audio) ? ' · 소리 포함' : ''} · 4:5·1:1 파생 무료`);
95
+ lines.push(`영상 ${conceptsWithVideo * perConcept.length}편(방향 ${conceptsWithVideo} × 포맷 ${perConcept.map((v) => { const k = sceneCuts(v.format || 'cinematic', v.durationSec, tone); return `${FORMAT_LABEL_SHORT[v.format || 'cinematic']} ${v.durationSec}초${k > 1 ? `(${k}컷)` : ''}`; }).join('·')}) ≈ ₩${videosKrw.toLocaleString()}${perConcept.some((v) => v.audio) ? ' · 소리 포함' : ''} · 4:5·1:1 파생 무료`);
94
96
  lines.push(`예상 원가 ₩${totalKrw.toLocaleString()} → 청구 ₩${chargeFor(totalKrw).toLocaleString()}(원가의 1.1배 · 실제 생성된 것만, 만들어진 뒤 정확한 원가로 차감)`);
95
97
  const explain = [`소재 형태: ${MODE_LABEL[mode]} · 출처: ${SOURCE_LABEL[source]} · 톤: ${tone} · 업종 플레이북: ${pb.label}`, ...lines].join('\n');
96
98
  return { mode, source, concepts, tone, formats, images: { enabled: imagesEnabled, tier: imgTier, model: img.model, variants, ratios, unitKrw: img.unitKrw }, videos: { perConcept, conceptsWithVideo }, estimate: { imagesKrw, videosKrw, totalKrw, chargedKrw: chargeFor(totalKrw), lines }, explain };
@@ -8,6 +8,18 @@ export declare const TONE_WORDS: Record<Tone, {
8
8
  music: string;
9
9
  caption: string;
10
10
  }>;
11
+ /** 템포(사장님 2026-09-17 검수 「시네마틱·라이프스타일이 너무 느긋 → 재미·긴박감 · 빠른 템포 · 음악·전개도」) — 장면 포맷(사람이 말하지 않는 컷)은 컷 수를 늘리고(훅→전개→보상) 붙일 때 배속한다.
12
+ * cuts = 8초 안의 컷 수(각 4초 생성) · speed = 이어붙일 때 배속(Veo 는 느긋하게 움직여 1.25~1.35배가 「보통 속도」로 보임 · 말하는 컷엔 적용 안 함) */
13
+ export type Pace = {
14
+ cuts: number;
15
+ speed: number;
16
+ words: string;
17
+ };
18
+ export declare const PACE: Record<Tone, Pace>;
19
+ /** 장면 포맷(사람이 말하지 않는 · 배속·컷 늘리기 대상) */
20
+ export declare const SCENE_FORMATS: VideoFormat[];
21
+ /** 8초 이하 장면 포맷의 컷 수 — 제품 히어로는 최대 2컷(제품 사진 첫 프레임 → 사용 장면) */
22
+ export declare function sceneCuts(format: VideoFormat, durationSec: number, tone: Tone): number;
11
23
  export type Playbook = {
12
24
  key: string;
13
25
  label: string;
@@ -2,17 +2,32 @@ export const FORMAT_LABEL = { ugc_selfie: '셀카 후기(UGC)', ui_demo: '화면
2
2
  export const FORMAT_DESC = {
3
3
  ugc_selfie: '실제 고객처럼 보이는 인물이 셀카로 한마디 — 네이티브 톤, 소비재·앱에서 CPM 낮고 CTR 높음',
4
4
  ui_demo: '실제 사이트/앱 화면이 폰 안에서 움직임 — 앱·SaaS·쇼핑몰에서 「무엇인지」를 3초 안에 보여줌(생성 비용 0)',
5
- cinematic: '장르(실사·애니·3D)에 맞는 연출 컷 2개(훅 → 보상) — 브랜드 무드·세계관',
5
+ cinematic: '장르(실사·애니·3D)에 맞는 연출 컷 3개(훅 → 전개 → 보상 · 빠른 템포) — 브랜드 무드·세계관',
6
6
  product_hero: '제품/결과물이 주인공인 클로즈업·회전·질감 — 뷰티·식품·패션',
7
- lifestyle: '타겟이 제품을 쓰는 장면 — 여행·피트니스·가전',
7
+ lifestyle: '타겟이 제품을 쓰는 장면 3컷(상황 → 반전 → 보상 · 빠른 템포) — 여행·피트니스·가전',
8
8
  text_hook: '큰 글자 질문/숫자로 시작해 증거로 이어가는 텍스트 중심 — B2B·교육·서비스',
9
9
  };
10
10
  export const TONE_LABEL = { calm: '차분하게', lively: '활기차게', bold: '강하게' };
11
11
  export const TONE_WORDS = {
12
- calm: { camera: 'smooth, steady camera, gentle motion, soft natural light', music: 'soft, warm, understated background music', caption: '차분한 서술형' },
13
- lively: { camera: 'energetic handheld feel, quick push-ins, dynamic motion in the first second, bright lighting', music: 'upbeat, modern, rhythmic music with a clear beat', caption: '짧고 리듬 있는 구어체' },
14
- bold: { camera: 'punchy fast cuts, dramatic close-ups, strong contrast, high energy from frame one', music: 'driving, bold, high-energy music with impact hits', caption: '짧고 강한 선언형' },
12
+ calm: { camera: 'steady but never sluggish camera, purposeful motion from the first frame, soft natural light', music: 'warm, modern background music with a steady pulse (around 100 BPM), never sleepy', caption: '차분한 서술형' },
13
+ lively: { camera: 'energetic handheld feel, quick push-ins and whip pans, something moves within the first half second, bright lighting', music: 'fast, upbeat, rhythmic music (120-135 BPM) with a clear beat and percussive hits on cuts', caption: '짧고 리듬 있는 구어체' },
14
+ bold: { camera: 'punchy fast cuts, snap zooms, dramatic close-ups, strong contrast, high energy from frame one', music: 'driving, bold, high-energy music (130-150 BPM) with impact hits and risers', caption: '짧고 강한 선언형' },
15
15
  };
16
+ export const PACE = {
17
+ calm: { cuts: 2, speed: 1.1, words: '차분하지만 처지지 않게 — 컷마다 동작이 있고, 풍경만 흐르는 컷 금지' },
18
+ lively: { cuts: 3, speed: 1.25, words: '빠른 템포 — 컷마다 비트 2개(동작 A → 스냅/휩팬 → 동작 B) · 놀람·반전·유머 중 하나 · 첫 0.5초 안에 움직임' },
19
+ bold: { cuts: 3, speed: 1.35, words: '긴박감 — 컷마다 비트 2개 · 카운트다운·추격·반전 같은 긴장 장치 · 스냅 줌 · 첫 0.5초 안에 움직임' },
20
+ };
21
+ /** 장면 포맷(사람이 말하지 않는 · 배속·컷 늘리기 대상) */
22
+ export const SCENE_FORMATS = ['cinematic', 'lifestyle', 'text_hook', 'product_hero'];
23
+ /** 8초 이하 장면 포맷의 컷 수 — 제품 히어로는 최대 2컷(제품 사진 첫 프레임 → 사용 장면) */
24
+ export function sceneCuts(format, durationSec, tone) {
25
+ if (durationSec > 8 || !SCENE_FORMATS.includes(format))
26
+ return 1;
27
+ const c = PACE[tone].cuts;
28
+ return format === 'product_hero' ? Math.min(2, c) : c;
29
+ }
30
+ // 기본 톤: 광고는 느긋하면 넘겨진다(2026-09-17 사장님) → 규제 업종(건기식·금융·의료)만 calm · 항공·건설·B2B 도 lively
16
31
  const P = (key, label, formats, hooks, genres, musts, avoid, tone = 'lively') => ({ key, label, formats, hooks, genres, musts, avoid, tone });
17
32
  export const INDUSTRIES = {
18
33
  app_game: P('app_game', '앱·게임·엔터·웹툰', ['ugc_selfie', 'ui_demo', 'cinematic'], ['「이거 해봤어?」 반응 셀카', '캐릭터/세계관 컷 → 실제 화면', '선택지 두 개를 보여주고 결말 예고'], ['anime', 'ugc', '3d'], ['실제 앱 화면 3초 안에', '재미·몰입의 순간'], ['현금·보상 과장', '성인 암시', '타사 IP·실존 인물'], 'lively'),
@@ -23,11 +38,11 @@ export const INDUSTRIES = {
23
38
  ecommerce_food: P('ecommerce_food', '식품·음료·농산물', ['product_hero', 'ugc_selfie', 'lifestyle'], ['한 입·시즐(김·소리)', '산지/생산 장면 → 식탁', '「오늘 주문 → 내일 도착」(사이트 근거)'], ['photoreal', 'product_shot', 'ugc'], ['먹는 장면·질감', '원산지·신선함'], ['건강 효능 단정', '가격 오표기'], 'lively'),
24
39
  education: P('education', '교육·강의·학원', ['text_hook', 'ugc_selfie', 'ui_demo'], ['「이 문제 풀 수 있어요?」', '수강생 셀카 후기(합격·성과는 근거 있을 때만)', '커리큘럼 첫 장면'], ['photoreal', 'motion_graphics', 'ugc'], ['무엇을 배우는지 3초', '강사/교재 실제 화면'], ['합격 보장', '타 기관 비교'], 'lively'),
25
40
  travel: P('travel', '여행·숙박·투어', ['lifestyle', 'cinematic', 'ugc_selfie'], ['목적지 첫 장면 3개 빠른 컷', '「이 가격에?」(실제 가격만)', '여행자 셀카 리액션'], ['photoreal', 'ugc'], ['목적지·숙소·경험 장면', '가격·기간(사이트 근거)'], ['가짜 「마감 임박」', '타사 항공·호텔 로고'], 'lively'),
26
- airline: P('airline', '항공', ['cinematic', 'lifestyle', 'text_hook'], ['이륙/창밖 → 도착지', '좌석·서비스 디테일', '「○○행 ○○원부터」(실제 운임만)'], ['photoreal'], ['노선·좌석·서비스', '운임 조건'], ['안전 관련 과장', '경쟁사 언급'], 'calm'),
27
- realestate_construction: P('realestate_construction', '건설·부동산·인테리어', ['cinematic', 'text_hook', 'lifestyle'], ['완공 드론 → 내부 워크스루', '「이 동네에 이런 집?」', '시공 전→후(주거 허용·정직하게)'], ['photoreal', '3d'], ['실제 조감·평면·위치', '시공 품질'], ['수익률·시세 상승 단정', '허위 분양 조건'], 'calm'),
41
+ airline: P('airline', '항공', ['cinematic', 'lifestyle', 'text_hook'], ['이륙/창밖 → 도착지', '좌석·서비스 디테일', '「○○행 ○○원부터」(실제 운임만)'], ['photoreal'], ['노선·좌석·서비스', '운임 조건'], ['안전 관련 과장', '경쟁사 언급'], 'lively'),
42
+ realestate_construction: P('realestate_construction', '건설·부동산·인테리어', ['cinematic', 'text_hook', 'lifestyle'], ['완공 드론 → 내부 워크스루', '「이 동네에 이런 집?」', '시공 전→후(주거 허용·정직하게)'], ['photoreal', '3d'], ['실제 조감·평면·위치', '시공 품질'], ['수익률·시세 상승 단정', '허위 분양 조건'], 'lively'),
28
43
  finance: P('finance', '금융·보험·투자', ['text_hook', 'ugc_selfie'], ['「매달 이만큼 새나가요」(근거 있을 때만)', '한 줄 질문'], ['photoreal', 'motion_graphics'], ['상품 조건·수수료'], ['수익 보장', '공포 소구'], 'calm'),
29
44
  medical: P('medical', '의료·병원·시술', ['text_hook', 'cinematic'], ['시설·의료진(실존 인물 초상 동의 필요)'], ['photoreal'], ['진료 과목·위치'], ['전후 비교', '치료 효과 단정', '환자 후기 연출'], 'calm'),
30
- b2b_service: P('b2b_service', 'B2B 서비스·컨설팅·제조', ['text_hook', 'ui_demo', 'cinematic'], ['「담당자님, 이거 아직도?」', '숫자 훅(근거)', '결과물 실물/화면'], ['photoreal', 'motion_graphics'], ['누구의 어떤 문제를 푸는지', '결과물·레퍼런스(공개 가능한 것)'], ['고객사 로고 무단 사용'], 'calm'),
45
+ b2b_service: P('b2b_service', 'B2B 서비스·컨설팅·제조', ['text_hook', 'ui_demo', 'cinematic'], ['「담당자님, 이거 아직도?」', '숫자 훅(근거)', '결과물 실물/화면'], ['photoreal', 'motion_graphics'], ['누구의 어떤 문제를 푸는지', '결과물·레퍼런스(공개 가능한 것)'], ['고객사 로고 무단 사용'], 'lively'),
31
46
  local_service: P('local_service', '지역 서비스(음식점·미용·학원·병원 외)', ['ugc_selfie', 'lifestyle', 'product_hero'], ['매장 첫 장면 + 대표 메뉴/서비스', '사장님 한마디', '「○○역 3분」(사이트 근거)'], ['photoreal', 'ugc'], ['실제 매장·서비스 장면', '위치·영업시간'], ['가짜 후기'], 'lively'),
32
47
  automotive: P('automotive', '자동차·모빌리티', ['cinematic', 'product_hero', 'lifestyle'], ['주행 첫 컷 → 디테일', '「월 ○○원」(실제 조건)'], ['photoreal', '3d'], ['외관·실내·주행'], ['안전 과장', '경쟁 모델 비방'], 'bold'),
33
48
  fitness: P('fitness', '피트니스·운동·다이어트', ['ugc_selfie', 'lifestyle', 'text_hook'], ['운동 동작 첫 컷', '루틴 셀카', '「하루 10분」(프로그램 근거)'], ['photoreal', 'ugc'], ['운동 장면·프로그램'], ['전후 몸매 비교', '체중 감량 단정', '신체 수치 지칭'], 'bold'),
@@ -52,7 +67,7 @@ export function pickFormats(o) {
52
67
  export const GENRE_STYLE = {
53
68
  photoreal: 'photorealistic, natural skin and materials, shot on a modern mirrorless camera, realistic lighting',
54
69
  ugc: 'authentic user-generated smartphone footage look, handheld, natural indoor light, real-person casual vibe, no studio polish',
55
- anime: 'high-quality Japanese anime style, clean linework, vivid cel shading, dramatic sky and light, studio-quality animation',
70
+ anime: 'high-quality Japanese anime style, clean linework, vivid cel shading, expressive characters with dynamic action poses, speed lines, glowing particle effects, dramatic sky and light, studio-quality animation — a rich animated world (characters, places, action), never a plain logo or static graphic',
56
71
  '3d': 'stylized 3D render, soft global illumination, Pixar-like character appeal, clean materials',
57
72
  illustration: 'flat editorial illustration, bold shapes, limited palette, tasteful texture',
58
73
  // 🔴 AI 가 그리는 글자·숫자는 깨진다(SaaS 글자 훅에 의미 없는 숫자 · 2026-09-17) → 모션그래픽은 추상 도형만, 글자는 우리 자막이 담당
@@ -25,3 +25,5 @@ export declare function classifyRefImage(image: string): Promise<{
25
25
  export declare function containsPerson(image: string): Promise<boolean | null>;
26
26
  /** 자동 수집 이미지 중 「사람 없음 · 실물 제품 · 그래픽 아님」만(판정 불가면 제외 — 보수적) */
27
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>;
@@ -63,7 +63,8 @@ export async function checkSpeech(video, o) {
63
63
  problems.push(`대본과 다름(${Math.round(sim * 100)}%)`);
64
64
  if (garbled)
65
65
  problems.push('발음 불명확');
66
- if (cutoff)
66
+ // 🔴 「끊김」 판정은 대본이 다 들렸으면(≥85%) 무시 — 일본어 Seedance 클립이 전문 일치 100% 인데 cutoff 오탐으로 립싱크 클립이 TTS 로 대체된 실사고(2026-09-17). 실제 꼬리 끊김은 tailLoudnessDb 가 따로 잡는다
67
+ if (cutoff && sim < 0.85)
67
68
  problems.push('끝에서 끊김');
68
69
  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
  }
@@ -156,3 +157,31 @@ export async function filterProductRefs(files, log) {
156
157
  }
157
158
  return out;
158
159
  }
160
+ /** 비주얼 결함 — 3프레임을 qwen 비전에: 중복/이중 물체 · 손가락·팔 이상 · 얼굴 왜곡 · 물리적으로 불가능한 제품(쿠션에서 마스카라 브러시) · 깨진 글자. 실패면 재생성 대상 */
161
+ export async function checkArtifacts(video) {
162
+ const cfg = imageConfig();
163
+ if (!cfg || cfg.provider !== 'bizrouter')
164
+ return { ok: true, skipped: true, note: '결함 검사 건너뜀(키 없음)' };
165
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-qa-'));
166
+ try {
167
+ const p = await probe(video);
168
+ const frames = [];
169
+ for (const t of [0.6, p.durationSec / 2, Math.max(0.8, p.durationSec - 0.6)]) {
170
+ const f = path.join(dir, `f${frames.length}.jpg`);
171
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(2), '-i', video, '-frames:v', '1', '-vf', 'scale=540:-1', '-q:v', '4', f]);
172
+ frames.push(f);
173
+ }
174
+ 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) });
175
+ const j = (await res.json());
176
+ const m = /\{[\s\S]*\}/.exec(j.choices?.[0]?.message?.content || '');
177
+ const d = JSON.parse(m ? m[0] : '{}');
178
+ const bad = d.defects === true && (d.severity ?? 3) >= 2;
179
+ return { ok: !bad, note: bad ? `비주얼 결함: ${(d.issues || []).slice(0, 3).join(' · ')}` : `비주얼 검사 ✓${d.defects ? ` (경미: ${(d.issues || [])[0] || ''})` : ''}`, data: d };
180
+ }
181
+ catch (e) {
182
+ return { ok: true, skipped: true, note: `결함 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 60) : e})` };
183
+ }
184
+ finally {
185
+ fs.rmSync(dir, { recursive: true, force: true });
186
+ }
187
+ }
@@ -18,6 +18,8 @@ type El = {
18
18
  /** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
19
19
  export declare function onColor(hex: string): string;
20
20
  /** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
21
+ /** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
22
+ export declare function cutWords(text: string, limit: number): string;
21
23
  export declare function wordsEl(text: string, style: Record<string, unknown>, align?: 'left' | 'center'): El;
22
24
  /** 한 규격의 트리 */
23
25
  export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
@@ -98,6 +98,15 @@ const h = (type, style, children, extra = {}) => ({ type, props: { style, ...ext
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
100
  /** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
101
+ /** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
102
+ export function cutWords(text, limit) {
103
+ const t = String(text || '').trim();
104
+ if (t.length <= limit)
105
+ return t;
106
+ const cut = t.slice(0, limit + 1);
107
+ const sp = cut.lastIndexOf(' ');
108
+ return (sp > limit * 0.5 ? cut.slice(0, sp) : t.slice(0, limit)).replace(/[\s,·:;\-—]+$/, '');
109
+ }
101
110
  export function wordsEl(text, style, align = 'left') {
102
111
  const words = String(text || '').split(/\s+/).filter(Boolean);
103
112
  const gap = Math.round(Number(style.fontSize || 16) * 0.26);
@@ -130,9 +139,11 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
130
139
  // 구글 디스플레이 이미지는 반대로 글자를 적게(구글이 헤드라인·설명을 따로 얹고, 이미지 텍스트 20% 초과는 비승인 위험) → 헤드라인만 작게
131
140
  const googleLight = size.medium === 'google' && !banner;
132
141
  // 구글 이미지(1200×628·1200×1200)는 텍스트 20% 안에서 헤드라인 8.5% + 본문 한 줄 3.6% — 너무 비면 썰렁(사장님 검수) · 메타보다 한 단계 작게
133
- 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.085) : Math.round(base * (hh > w ? 0.12 : 0.105));
134
- const bodyFs = short ? 0 : narrow ? Math.round(w * 0.085) : banner ? Math.round(base * 0.062) : googleLight ? Math.round(base * 0.036) : Math.round(base * 0.046);
135
- 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.036) : Math.round(base * 0.04);
142
+ // 🔴 가로로 포스터(1200×628 · 1920×1080) 짧은 기준이라 대비 글자가 절반 크기로 보임(2026-09-17 사장님 「가로 이미지 글자 작다」) 1.35배 키움(구글 텍스트 20% · 실측 ≈11%)
143
+ const wideBoost = !banner && !short && !narrow && w / hh >= 1.3 ? 1.35 : 1;
144
+ const headFs = Math.round((short ? hh * 0.34 : narrow ? w * 0.14 : banner ? base * 0.12 : googleLight ? base * 0.085 : base * (hh > w ? 0.12 : 0.105)) * wideBoost);
145
+ const bodyFs = short ? 0 : Math.round((narrow ? w * 0.085 : banner ? base * 0.062 : googleLight ? base * 0.036 : base * 0.046) * wideBoost);
146
+ const ctaFs = Math.round((short ? hh * 0.28 : narrow ? w * 0.09 : banner ? base * 0.06 : googleLight ? base * 0.036 : base * 0.04) * wideBoost);
136
147
  const children = [];
137
148
  if (bg)
138
149
  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 }));
@@ -146,7 +157,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
146
157
  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]));
147
158
  }
148
159
  else if (narrow) {
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]));
160
+ 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(cutWords(body, 60), { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4 })]), ctaPill]));
150
161
  }
151
162
  else {
152
163
  const isWide = w / hh > 1.6;
@@ -154,7 +165,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
154
165
  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', {}, '')]),
155
166
  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 }, [
156
167
  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 }),
168
+ (banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(banner ? cutWords(body, 70) : googleLight ? cutWords(body, 48) : body, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }),
158
169
  h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
159
170
  ]),
160
171
  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)]),
@@ -211,7 +222,8 @@ export async function renderLogo(dir, brief) {
211
222
  /** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
212
223
  export async function renderOverlay(file, w, hh, text, o) {
213
224
  const fonts = await loadFonts(o.lang);
214
- const base = Math.min(w, hh);
225
+ // 가로 영상(16:9)은 짧은 변 기준 글자가 폭 대비 작아 보임 → 1.35배(2026-09-17)
226
+ const base = Math.min(w, hh) * (w / hh >= 1.3 ? 1.35 : 1);
215
227
  const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
216
228
  const pill = o.kind === 'cta'
217
229
  ? 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)
@@ -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;
@@ -37,8 +40,15 @@ export type I18n = Record<string, LocalizedCopy>;
37
40
  export declare const langName: (l: string) => string;
38
41
  /** 훅 길이 제한 — 한글·전각 12자(wlen 24) · 라틴은 단어 경계에서 자른다(잘린 단어 금지) */
39
42
  export declare function fitHook(t: string): string;
40
- /** 포맷별 클립 구성 규칙 — 8초: UGC 1×8(발화) · 시네마틱/라이프스타일 2×4(훅→보상) · ui_demo 4(훅 gen)+4(화면) · 제품 8 · text_hook 2×4. 15/30초는 1클립 다중 장면 */
41
- export declare function clipPlan(format: VideoFormat, durationSec: number, hasScreens: boolean): {
43
+ /** 대사 길이 규칙 — 빠르게 말하면(신난 친구 톤 · 영어 ≈3.3단어/초 · 한국어·일본어 ≈7자/초) 6초에 이만큼 들어간다. 8 클립에서 마지막 1초는 말 없이 미소. (2026-09-17 사장님 「말이 너무 느려 · 빨리 치면 대사 들어가도 된다」) */
44
+ export declare const SPEECH_LIMIT: {
45
+ enWords: number;
46
+ cjkChars: number;
47
+ speakSec: string;
48
+ };
49
+ export declare const speechRule: () => string;
50
+ /** 포맷별 클립 구성 규칙 — 8초: UGC 1×8(발화) · 장면 포맷(시네마틱/라이프스타일/글자 훅)은 톤 템포에 따라 2~3×4(훅→전개→보상) · 제품 히어로 1~2컷 · ui_demo 4(훅 gen)+4(화면). 15/30초는 1클립 다중 장면 */
51
+ export declare function clipPlan(format: VideoFormat, durationSec: number, hasScreens: boolean, tone?: Tone): {
42
52
  sec: number;
43
53
  kind: 'gen' | 'ui_demo';
44
54
  }[];
@@ -3,11 +3,12 @@
3
3
  import { z } from 'zod';
4
4
  import { aiAvailable, completeJson } from '../llm.js';
5
5
  import { ctaLabel } from './render.js';
6
- import { FORMAT_DESC, genreStyle, playbookFor, TONE_WORDS } from './playbook.js';
6
+ import { FORMAT_DESC, genreStyle, PACE, playbookFor, sceneCuts, 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(4),
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;
@@ -27,15 +28,21 @@ export function fitHook(t) {
27
28
  }
28
29
  return out || fit(s, 30);
29
30
  }
30
- /** 포맷별 클립 구성 규칙 — 8초: UGC 1×8(발화) · 시네마틱/라이프스타일 2×4(훅→보상) · ui_demo 4(훅 gen)+4(화면) · 제품 8 · text_hook 2×4. 15/30초는 1클립 다중 장면 */
31
- export function clipPlan(format, durationSec, hasScreens) {
31
+ /** 대사 길이 규칙 — 빠르게 말하면(신난 친구 톤 · 영어 ≈3.3단어/초 · 한국어·일본어 ≈7자/초) 6초에 이만큼 들어간다. 8 클립에서 마지막 1초는 말 없이 미소. (2026-09-17 사장님 「말이 너무 느려 · 빨리 치면 대사 들어가도 된다」) */
32
+ export const SPEECH_LIMIT = { enWords: 20, cjkChars: 40, speakSec: '5~6초' };
33
+ export const speechRule = () => `**빠르게 말하는 한두 문장(영어 ≤ ${SPEECH_LIMIT.enWords}단어 · 한국어/일본어 ≤ ${SPEECH_LIMIT.cjkChars}자 · 신나서 빨리 말하면 ${SPEECH_LIMIT.speakSec})**`;
34
+ /** 포맷별 클립 구성 규칙 — 8초: UGC 1×8(발화) · 장면 포맷(시네마틱/라이프스타일/글자 훅)은 톤 템포에 따라 2~3×4(훅→전개→보상) · 제품 히어로 1~2컷 · ui_demo 4(훅 gen)+4(화면). 15/30초는 1클립 다중 장면 */
35
+ export function clipPlan(format, durationSec, hasScreens, tone = 'lively') {
32
36
  if (durationSec > 8)
33
37
  return [{ sec: durationSec, kind: 'gen' }];
34
- if (format === 'ugc_selfie' || format === 'product_hero')
38
+ if (format === 'ugc_selfie')
35
39
  return [{ sec: 8, kind: 'gen' }];
36
40
  if (format === 'ui_demo')
37
41
  return hasScreens ? [{ sec: 4, kind: 'gen' }, { sec: 4, kind: 'ui_demo' }] : [{ sec: 8, kind: 'gen' }];
38
- return [{ sec: 4, kind: 'gen' }, { sec: 4, kind: 'gen' }];
42
+ const cuts = sceneCuts(format, durationSec, tone);
43
+ if (cuts <= 1)
44
+ return [{ sec: 8, kind: 'gen' }];
45
+ return Array.from({ length: cuts }, () => ({ sec: 4, kind: 'gen' }));
39
46
  }
40
47
  export async function generateStoryboards(brief, concept, o) {
41
48
  const lang = o.lang || brief.language;
@@ -43,7 +50,8 @@ export async function generateStoryboards(brief, concept, o) {
43
50
  const pb = playbookFor(brief.industry);
44
51
  const genres = brief.visualGenres?.length ? brief.visualGenres : pb.genres;
45
52
  const tone = TONE_WORDS[o.tone];
46
- const plans = o.formats.map((f) => ({ format: f, clips: clipPlan(f, o.durationSec, o.hasScreens) }));
53
+ const pace = PACE[o.tone];
54
+ const plans = o.formats.map((f) => ({ format: f, clips: clipPlan(f, o.durationSec, o.hasScreens, o.tone) }));
47
55
  if (!aiAvailable())
48
56
  return plans.map((p) => fallbackBoard(brief, concept, p.format, p.clips, genres[0], cta, lang, o.durationSec));
49
57
  const out = await completeJson({
@@ -52,16 +60,19 @@ export async function generateStoryboards(brief, concept, o) {
52
60
  업종 플레이북(${pb.label}): 포맷 우선 ${pb.formats.join('·')} · 훅 패턴 ${pb.hooks.join(' / ')} · 꼭 보일 것 ${pb.musts.join('·')} · 피할 것 ${pb.avoid.join('·')}.
53
61
  비주얼 장르(우선순위): ${genres.join(' > ')} — ${genres.map((g) => `${g}=${genreStyle(g)}`).join(' | ')}. 애니(anime)는 브랜드가 애니·게임·웹툰일 때만 쓴다.
54
62
  톤: ${o.tone} → 카메라 「${tone.camera}」 · 음악 「${tone.music}」 · 자막 ${tone.caption}.
63
+ 템포(중요 · 광고는 느긋하면 넘겨진다): ${pace.words}. 장면 컷이 3개면 「훅(문제·놀람) → 전개(반전·행동) → 보상(결과·제품)」 순서로 이야기가 앞으로 달린다. 금지: 풍경만 천천히 흐르는 컷 · 가만히 서서 웃기만 하는 인물 · 로고·앱 아이콘만 떠 있는 컷 · 느린 페이드. 음악은 컷마다 비트가 떨어지는 빠른 곡 · 전환에 whoosh/impact 효과음.
55
64
  포맷 정의: ${o.formats.map((f) => `${f}=${FORMAT_DESC[f]}`).join(' | ')}.
56
65
  클립 구성(포맷별 고정 · 초 수를 지켜라): ${plans.map((p) => `${p.format}: ${p.clips.map((c) => `${c.sec}s ${c.kind}`).join(' + ')}`).join(' / ')}.
57
66
  규칙:
58
- - boards 는 요청 포맷마다 정확히 1개. 각 board: format · genre(위 장르 중 하나) · hook(첫 프레임 큰 글자 · ${lang} · 6단어/12자 이내 · 질문/숫자/반전) · captions(화면 자막 3줄 · ${lang} · 각 18자 이내 · 훅→가치/증명→행동 순 · 마지막 줄은 행동 유도) · voice(선택 · ${lang} 문장 내레이션/대사) · music(영어 한 줄).
59
- - clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
60
- - ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 문장(영어 12단어 · 한국어/일본어 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다( 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
67
+ - boards 는 요청 포맷마다 정확히 1개. 각 board: format · genre(위 장르 중 하나) · hook(첫 프레임 큰 글자 · ${lang} · 6단어/12자 이내 · 질문/숫자/반전) · captions(화면 자막 3줄 · ${lang} · 각 18자 이내 · 훅→가치/증명→행동 순 · 마지막 줄은 행동 유도) · voice(선택 · ${lang} 내레이션 한두 문장 · 빠르게 읽어 5~6초 · 장면 포맷은 이 내레이션이 소리를 담당하므로 꼭 쓴다) · music(영어 한 줄).
68
+ - clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨 · 4초 컷 안에 비트 2개(예 "she slams the laptop shut — whip pan — she's already out the door") · 말하지 않는 컷은 "fast-paced, energetic" 를 명시. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
69
+ - ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 **빠르게, 신나서** 말한다 — speech 는 ${speechRule()} 이어야 8초 안에 끝난다(마지막 1초는 미소 · 끊김 금지) · 느릿한 문장보다 빠른 문장이 낫다( 마디 + 이유 한 마디) · 프롬프트에 "speaking quickly and excitedly to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
61
70
  - ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
62
71
  - text_hook 포맷의 clips[].prompt 는 「자막 뒤에 깔릴 배경 장면」이다 — 타겟이 일하는/사용하는 실사 장면(사람 뒷모습·손·사무실·제품 사용) 또는 추상 도형. 화면 안에 글자·숫자·차트 라벨·UI 를 그리지 말 것(우리 자막이 글자를 담당 · AI 글자는 깨진다).
72
+ - 사람이 나오는 board 는 cast 에 인물 외형 고정 문장(영어 1문장 · 성별/나이대/인종·피부톤/머리/옷/소품 · 브리프 타겟에 맞게)을 쓰고, 그 인물이 이어지는 컷은 clips[].continues=true(앞 컷의 마지막 프레임에서 이어짐 · 인물·장소 동일). 인물 없는 컷은 continues=false.
73
+ - 제품은 물리적으로 말이 되게 — 실제 제품 종류 하나만(쿠션에서 마스카라 브러시가 나오는 식 금지) · 한 컷에 한 제품 · 손과 제품 비율 자연스럽게.
63
74
  - 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
64
- - 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
75
+ - 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,cast,clips:[{sec,kind,prompt,speech,continues}]}]}`,
65
76
  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 }),
66
77
  });
67
78
  const boards = [];
@@ -71,15 +82,15 @@ export async function generateStoryboards(brief, concept, o) {
71
82
  boards.push(fallbackBoard(brief, concept, p.format, p.clips, genres[0], cta, lang, o.durationSec));
72
83
  continue;
73
84
  }
74
- 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 }; });
75
- 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 });
85
+ 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 }; });
86
+ 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 });
76
87
  }
77
88
  return boards;
78
89
  }
79
90
  function fallbackBoard(brief, concept, format, plan, genre, cta, lang, durationSec) {
80
91
  const style = genreStyle(genre);
81
92
  const base = concept.videoPrompt || `A short advertisement scene inspired by: ${concept.imagePrompt}.`;
82
- const clips = plan.map((p, i) => p.kind === 'ui_demo' ? { sec: p.sec, kind: 'ui_demo', prompt: concept.headlines[0] } : { sec: p.sec, kind: 'gen', prompt: `${i === 0 ? 'Opening hook shot with immediate motion: ' : 'Payoff shot: '}${base} ${style}. Upbeat modern music. No on-screen text, no subtitles, no logos.` });
93
+ const clips = plan.map((p, i) => p.kind === 'ui_demo' ? { sec: p.sec, kind: 'ui_demo', prompt: concept.headlines[0] } : { sec: p.sec, kind: 'gen', prompt: `${i === 0 ? 'Opening hook shot with immediate motion: ' : i < plan.length - 1 ? 'Fast-paced development shot with a twist: ' : 'Payoff shot: '}${base} ${style}. Fast-paced, energetic. Upbeat modern music with a clear beat. No on-screen text, no subtitles, no logos.` });
83
94
  return { id: `${concept.key}_${format}`, format, genre: format === 'ugc_selfie' ? 'ugc' : genre, hook: fitHook(concept.headlines[0] || brief.company), captions: (concept.videoLines?.length ? concept.videoLines : [concept.headlines[0], concept.bodies[0]?.slice(0, 18) || '', cta]).filter(Boolean).slice(0, 3), music: 'upbeat modern music', cta, durationSec, clips, lang };
84
95
  }
85
96
  /** 채점 — 훅 강도·3초 명료성·정책 위험·브랜드 적합·타겟 적합(각 0~10) → 평균. 정책 위험은 규칙 검사(policyIssues)로 먼저 깎는다. */
@@ -120,7 +131,7 @@ export async function translateCopy(brief, concept, boards, langs) {
120
131
  }
121
132
  const r = await completeJson({
122
133
  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,
123
- 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}]}}}`,
134
+ system: `너는 광고 현지화 카피라이터다. 아래 문구·자막·훅·대사를 각 언어로 옮긴다. 직역이 아니라 그 시장 광고에서 자연스러운 표현으로, 뜻·사실·숫자는 그대로. 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자(한글·전각 2자) · 설명 ≤ ${GOOGLE_LIMITS.description}자 · 자막 각 18자(영문 32자) 이내 · 훅 6단어 이내 · 대사(voice·speech)는 빠르게 말하면 5~6초 안에 끝나는 한두 문장(영어 ≤ 20단어 · 한국어/일본어40자 · 그 언어 원어민이 셀카에서 신나서 빠르게 말하는 구어체). 브랜드명(${brief.company})은 번역하지 않는다. 출력 JSON {"translations":{"<lang>":{headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice,speech}]}}}`,
124
135
  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 })) }),
125
136
  });
126
137
  // 키 정규화 — 모델이 "ja (Japanese)"·"Japanese"·"JA" 로 돌려주기도 함
@@ -137,7 +148,7 @@ export async function translateCopy(brief, concept, boards, langs) {
137
148
  if (!t) {
138
149
  // 한 언어만 다시(누락 보정) · 그래도 없으면 원문으로 채워 소재 생성은 막지 않는다
139
150
  try {
140
- 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 })) }) });
151
+ 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자) · 대사는 빠르게 5~6한두 문장(영어 ≤ 20단어 · 한국어/일본어 ≤ 40자). 출력 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 })) }) });
141
152
  t = one;
142
153
  }
143
154
  catch {
@@ -10,5 +10,6 @@ export declare function synthesizeSpeech(o: {
10
10
  lang: string;
11
11
  file: string;
12
12
  tone?: 'calm' | 'lively' | 'bold';
13
- persona?: string;
13
+ persona?: string; /** 이 초 안에 끝나야 하면 넘치는 만큼 배속(최대 1.4배 · 음높이 유지) */
14
+ fitSec?: number;
14
15
  }): Promise<TtsResult>;
@@ -1,5 +1,8 @@
1
1
  // 내레이션 TTS — BizRouter `/v1/audio/speech`(openai/gpt-4o-mini-tts · 한국어 자연도 실측 10/10). 영어가 아닌 시장에서 「말하는 포맷」의 음성은 Veo 발화 대신 TTS 를 얹는다(Veo 한국어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수).
2
2
  import fs from 'node:fs';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const execFileP = promisify(execFile);
3
6
  import { imageConfig } from './images.js';
4
7
  import { probe } from './videofx.js';
5
8
  const VOICE = { ko: 'nova', ja: 'shimmer', en: 'nova', zh: 'nova', default: 'alloy' };
@@ -10,12 +13,24 @@ export async function synthesizeSpeech(o) {
10
13
  if (!cfg || cfg.provider !== 'bizrouter')
11
14
  throw Object.assign(new Error('TTS 키(BizRouter)가 없어요'), { fatal: true });
12
15
  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}.` : ''}`;
16
+ // 2026-09-17 사장님 「말이 너무 느리다」 기본 빠른 템포(신난 친구가 소식 전하듯) · calm 처지지 않게
17
+ const style = o.tone === 'calm' ? 'warm, trustworthy, brisk but unhurried' : o.tone === 'bold' ? 'energetic, confident, punchy, fast' : 'casual, upbeat, excited, fast';
18
+ 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}. Speak quickly — about 20% faster than normal conversation, like an excited friend sharing news — with no long pauses, but every word clear. Not read from a script.${o.persona ? ` Speaker: ${o.persona}.` : ''}`;
15
19
  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
20
  if (!res.ok)
17
21
  throw new Error(`TTS ${res.status} ${(await res.text()).slice(0, 160)}`);
18
22
  fs.writeFileSync(o.file, Buffer.from(await res.arrayBuffer()));
19
- const p = await probe(o.file).catch(() => ({ durationSec: 0 }));
23
+ let p = await probe(o.file).catch(() => ({ durationSec: 0 }));
24
+ if (o.fitSec && p.durationSec > o.fitSec + 0.05) {
25
+ const tempo = Math.min(1.4, p.durationSec / o.fitSec);
26
+ const fast = o.file.replace(/\.mp3$/, '.fast.mp3');
27
+ try {
28
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', o.file, '-af', `atempo=${tempo.toFixed(3)}`, '-c:a', 'libmp3lame', '-q:a', '3', fast]);
29
+ fs.copyFileSync(fast, o.file);
30
+ fs.rmSync(fast, { force: true });
31
+ p = await probe(o.file).catch(() => p);
32
+ }
33
+ catch { /* 원본 유지 */ }
34
+ }
20
35
  return { file: o.file, durationSec: p.durationSec, costKrw: 15 };
21
36
  }
@@ -27,9 +27,12 @@ export declare function normalize(input: string, output: string, ratio: VideoRat
27
27
  /** 대표 프레임(썸네일) — 1초 지점 JPEG */
28
28
  export declare function thumbnail(input: string, output: string, atSec?: number): Promise<void>;
29
29
  /** 클립 이어붙이기 — 코덱·fps·오디오 정규화 뒤 concat(무음 클립은 무음 트랙 추가) · 컷 사이 0.25초 크로스페이드 없음(빠른 컷이 광고 표준) */
30
+ /** 컷 이어붙이기(하드컷) — o.speed 로 배속(영상 setpts + 소리 atempo · 음높이 유지). 말하는 컷은 배속하지 않는다(호출자 책임). 2026-09-17 템포 검수 */
30
31
  export declare function concatClips(inputs: string[], output: string, dims: {
31
32
  w: number;
32
33
  h: number;
34
+ }, o?: {
35
+ speed?: number;
33
36
  }): Promise<void>;
34
37
  /** UI 데모 클립 — 긴 스크린샷을 폰 프레임 안에서 아래로 스크롤(sec 초) · 배경 그래디언트 · 무음(뒤에서 음악 클립과 이어짐) */
35
38
  export declare function uiDemoClip(shot: string, output: string, o: {
@@ -64,7 +67,11 @@ export declare function kenBurnsClip(image: string, output: string, o: {
64
67
  export declare function pickHookBand(video: string, atSec?: number): Promise<'top' | 'middle' | 'lower'>;
65
68
  /** 마지막 tailSec 초의 평균 음량(dBFS) — 대사가 끝까지 이어져 「뚝」 끊기는지 판정(무음·음악만이면 낮음). 실패 시 -99. */
66
69
  export declare function tailLoudnessDb(file: string, tailSec?: number): Promise<number>;
67
- /** 내레이션 얹기 원본 소리는 낮추고(duck) 0.4초 뒤부터 TTS · 원본이 무음이면 TTS 만 */
70
+ /** 파일 전체 평균 음량(dB) 없으면 -99 */
71
+ export declare function meanVolumeDb(file: string): Promise<number>;
72
+ /** 평균 음량을 목표(dB)로 맞추는 고정 게인(dB) — 🔴 loudnorm(단일 패스·동적)은 사이드체인 덕킹을 되돌려 놓는다(실측 12dB → 2dB · 2026-09-17) → 고정 게인만 */
73
+ export declare function gainToDb(file: string, targetDb: number, min?: number, max?: number): Promise<number>;
74
+ /** 내레이션 얹기 — 원본 소리(음악)는 duck 배로 낮춘 뒤 **말하는 동안만 사이드체인으로 더 눌러**(≈-20dB) 목소리가 또렷하고, 말이 끝나면 음악이 돌아온다(2026-09-17 · 전엔 말소리 감지 시 원본을 0 으로 버려 내레이션 뒤 2초가 무음이었음) · 원본 평균 -20dB 고정 게인(Veo 원본은 -29dB 로 작음) · 원본이 무음이면 TTS 만 */
68
75
  export declare function mixVoiceover(video: string, voice: string, output: string, o?: {
69
76
  duck?: number;
70
77
  startSec?: number;
@@ -76,3 +83,5 @@ export declare function refCanvas(ref: string, bgPng: string, output: string, di
76
83
  }): Promise<string>;
77
84
  /** 움직임 점수 — 1초·중간·끝 프레임(작게) 사이 평균 픽셀 차. 0~255 · 3 미만이면 사실상 정지 화면(「이미지 하나 붙여놓은 영상」) */
78
85
  export declare function motionScore(video: string): Promise<number>;
86
+ /** 마지막 프레임 PNG — 다음 컷 첫 프레임(인물·장소 연속) */
87
+ export declare function lastFrame(video: string, output: string): Promise<string>;
@@ -65,8 +65,10 @@ export async function appendEndCard(input, cardPng, output, seconds = 2) {
65
65
  // 본편 정규화(24fps · yuv420p · 48k 스테레오 · 무음이면 무음 트랙) + 끝 0.6초 소리 페이드아웃
66
66
  const d = Math.max(0.5, p.durationSec);
67
67
  const fadeSt = Math.max(0, d - 0.6).toFixed(2);
68
+ // 마감 음량: 평균 -18dB 로 고정 게인(원본이 작은 Veo 소리 보정 · 클립 -6dB 한도)
69
+ const gain = p.hasAudio ? await gainToDb(input, -18) : 0;
68
70
  const args = p.hasAudio
69
- ? ['-y', '-v', 'error', '-i', input, '-vf', 'fps=24,format=yuv420p', '-af', `afade=t=out:st=${fadeSt}:d=0.6`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', main]
71
+ ? ['-y', '-v', 'error', '-i', input, '-vf', 'fps=24,format=yuv420p', '-af', `volume=${gain.toFixed(1)}dB,alimiter=limit=0.95,afade=t=out:st=${fadeSt}:d=0.6`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', main]
70
72
  : ['-y', '-v', 'error', '-i', input, '-f', 'lavfi', '-t', String(d), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', main];
71
73
  await execFileP('ffmpeg', args, { maxBuffer: 8 << 20 });
72
74
  const md = (await probe(main)).durationSec;
@@ -96,8 +98,10 @@ export async function thumbnail(input, output, atSec = 1) {
96
98
  await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', String(atSec), '-i', input, '-frames:v', '1', '-q:v', '3', output]);
97
99
  }
98
100
  /** 클립 이어붙이기 — 코덱·fps·오디오 정규화 뒤 concat(무음 클립은 무음 트랙 추가) · 컷 사이 0.25초 크로스페이드 없음(빠른 컷이 광고 표준) */
99
- export async function concatClips(inputs, output, dims) {
100
- if (inputs.length === 1) {
101
+ /** 이어붙이기(하드컷) — o.speed 로 배속(영상 setpts + 소리 atempo · 음높이 유지). 말하는 컷은 배속하지 않는다(호출자 책임). 2026-09-17 템포 검수 */
102
+ export async function concatClips(inputs, output, dims, o = {}) {
103
+ const speed = Math.max(0.5, Math.min(2, o.speed || 1));
104
+ if (inputs.length === 1 && Math.abs(speed - 1) < 0.01) {
101
105
  fs.copyFileSync(inputs[0], output);
102
106
  return;
103
107
  }
@@ -106,10 +110,15 @@ export async function concatClips(inputs, output, dims) {
106
110
  for (const [i, f] of inputs.entries()) {
107
111
  const p = await probe(f);
108
112
  const out = path.join(dir, `n${i}.mp4`);
109
- const base = ['-y', '-v', 'error', '-i', f, ...(p.hasAudio ? [] : ['-f', 'lavfi', '-t', String(Math.max(0.5, p.durationSec)), '-i', 'anullsrc=r=48000:cl=stereo']), '-vf', `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h},fps=24,format=yuv420p`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', ...(p.hasAudio ? [] : ['-shortest']), out];
113
+ const base = ['-y', '-v', 'error', '-i', f, ...(p.hasAudio ? [] : ['-f', 'lavfi', '-t', String(Math.max(0.5, p.durationSec)), '-i', 'anullsrc=r=48000:cl=stereo']), '-vf', `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h}${Math.abs(speed - 1) >= 0.01 ? `,setpts=PTS/${speed}` : ''},fps=24,format=yuv420p`, ...(Math.abs(speed - 1) >= 0.01 ? ['-af', `atempo=${speed}`] : []), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', ...(p.hasAudio ? [] : ['-shortest']), out];
110
114
  await execFileP('ffmpeg', base, { maxBuffer: 8 << 20 });
111
115
  norm.push(out);
112
116
  }
117
+ if (norm.length === 1) {
118
+ fs.copyFileSync(norm[0], output);
119
+ fs.rmSync(dir, { recursive: true, force: true });
120
+ return;
121
+ }
113
122
  const fc = norm.map((_, i) => `[${i}:v][${i}:a]`).join('') + `concat=n=${norm.length}:v=1:a=1[v][a]`;
114
123
  await execFileP('ffmpeg', ['-y', '-v', 'error', ...norm.flatMap((f) => ['-i', f]), '-filter_complex', fc, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
115
124
  fs.rmSync(dir, { recursive: true, force: true });
@@ -176,12 +185,31 @@ export async function tailLoudnessDb(file, tailSec = 0.7) {
176
185
  return -99;
177
186
  }
178
187
  }
179
- /** 내레이션 얹기 원본 소리는 낮추고(duck) 0.4초 뒤부터 TTS · 원본이 무음이면 TTS 만 */
188
+ /** 파일 전체 평균 음량(dB) 없으면 -99 */
189
+ export async function meanVolumeDb(file) {
190
+ try {
191
+ const { stderr } = await execFileP('ffmpeg', ['-v', 'info', '-i', file, '-vn', '-af', 'volumedetect', '-f', 'null', '-'], { maxBuffer: 4 << 20 });
192
+ const m = /mean_volume:\s*(-?[\d.]+) dB/.exec(String(stderr));
193
+ return m ? Number(m[1]) : -99;
194
+ }
195
+ catch {
196
+ return -99;
197
+ }
198
+ }
199
+ /** 평균 음량을 목표(dB)로 맞추는 고정 게인(dB) — 🔴 loudnorm(단일 패스·동적)은 사이드체인 덕킹을 되돌려 놓는다(실측 12dB → 2dB · 2026-09-17) → 고정 게인만 */
200
+ export async function gainToDb(file, targetDb, min = -6, max = 14) { const m = await meanVolumeDb(file); if (m <= -90)
201
+ return 0; return Math.max(min, Math.min(max, targetDb - m)); }
202
+ /** 내레이션 얹기 — 원본 소리(음악)는 duck 배로 낮춘 뒤 **말하는 동안만 사이드체인으로 더 눌러**(≈-20dB) 목소리가 또렷하고, 말이 끝나면 음악이 돌아온다(2026-09-17 · 전엔 말소리 감지 시 원본을 0 으로 버려 내레이션 뒤 2초가 무음이었음) · 원본 평균 -20dB 고정 게인(Veo 원본은 -29dB 로 작음) · 원본이 무음이면 TTS 만 */
180
203
  export async function mixVoiceover(video, voice, output, o = {}) {
181
204
  const p = await probe(video);
182
205
  const duck = o.duck ?? 0.18;
183
206
  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]`;
207
+ // 원본(음악)은 평균 -20dB 먼저 맞춘 (Veo 원본은 -29dB 로 작음) duck 배 · 목소리는 평균 -18dB
208
+ const bgGain = p.hasAudio ? await gainToDb(video, -20) : 0;
209
+ const voGain = await gainToDb(voice, -18, -8, 12);
210
+ const fc = p.hasAudio
211
+ ? `[1:a]adelay=${st}|${st},volume=${voGain.toFixed(1)}dB,apad=whole_dur=${(p.durationSec + 0.5).toFixed(2)}[vo0];[vo0]asplit=2[vo][sc];[0:a]volume=${bgGain.toFixed(1)}dB,volume=${Math.max(0.05, duck)}[bg0];[bg0][sc]sidechaincompress=threshold=0.01:ratio=20:attack=30:release=400:knee=1:level_sc=8[bg];[bg][vo]amix=inputs=2:duration=first:dropout_transition=0:normalize=0,alimiter=limit=0.95[a]`
212
+ : `[1:a]adelay=${st}|${st},volume=${voGain.toFixed(1)}dB,apad,atrim=0:${p.durationSec.toFixed(2)}[a]`;
185
213
  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
214
  }
187
215
  /** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
@@ -205,3 +233,8 @@ export async function motionScore(video) {
205
233
  return 99;
206
234
  }
207
235
  }
236
+ /** 마지막 프레임 PNG — 다음 컷 첫 프레임(인물·장소 연속) */
237
+ export async function lastFrame(video, output) {
238
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.15', '-i', video, '-frames:v', '1', '-update', '1', output]);
239
+ return output;
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",