adyou 0.6.4 → 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.
- package/dist/core/creatives/factory.js +21 -9
- package/dist/core/creatives/media.js +5 -3
- package/dist/core/creatives/playbook.d.ts +12 -0
- package/dist/core/creatives/playbook.js +24 -9
- package/dist/core/creatives/qa.js +2 -1
- package/dist/core/creatives/render.d.ts +2 -0
- package/dist/core/creatives/render.js +18 -6
- package/dist/core/creatives/storyboard.d.ts +9 -2
- package/dist/core/creatives/storyboard.js +21 -13
- package/dist/core/creatives/tts.d.ts +2 -1
- package/dist/core/creatives/tts.js +18 -3
- package/dist/core/creatives/videofx.d.ts +8 -1
- package/dist/core/creatives/videofx.js +34 -6
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
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';
|
|
@@ -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,9 +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
|
-
|
|
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.` : '';
|
|
128
130
|
const castLine = board.cast ? ` Character (identical in every shot — same face, hair, skin tone, outfit): ${board.cast}.` : '';
|
|
129
|
-
|
|
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.`;
|
|
130
134
|
// 첫 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) — 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 첫 프레임으로. 그 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
|
|
131
135
|
let firstFrame;
|
|
132
136
|
const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
|
|
@@ -151,8 +155,10 @@ export async function produceConceptVideos(o) {
|
|
|
151
155
|
if (clipModel !== job.model)
|
|
152
156
|
log(` ${langName(rl)} 대사는 Seedance 2.5 네이티브 발화로(립싱크)`);
|
|
153
157
|
let r;
|
|
158
|
+
// 참고 사진은 실사 장르에만 — 애니·3D·일러스트·모션그래픽 컷에 사진(특히 로고)을 참고로 넣으면 세계관 대신 로고가 흐른다(ISEKAI'D 실사고 · 2026-09-17)
|
|
159
|
+
const photoGenre = !['anime', '3d', 'illustration', 'motion_graphics'].includes(board.genre);
|
|
154
160
|
try {
|
|
155
|
-
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 });
|
|
156
162
|
}
|
|
157
163
|
catch (e) {
|
|
158
164
|
if (!e.retryable)
|
|
@@ -264,9 +270,14 @@ export async function produceConceptVideos(o) {
|
|
|
264
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 });
|
|
265
271
|
continue;
|
|
266
272
|
}
|
|
273
|
+
// 말하지 않는 장면 포맷은 톤 템포만큼 배속해 붙인다(Veo 는 느긋 · 1.25~1.35배가 「보통」으로 보임 · 말하는 컷은 원속도)
|
|
274
|
+
const speed = speaks || !SCENE_FORMATS.includes(format) ? 1 : pace.speed;
|
|
267
275
|
try {
|
|
268
|
-
if (ff)
|
|
269
|
-
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
|
+
}
|
|
270
281
|
else
|
|
271
282
|
fs.copyFileSync(clipFiles[0], rawFile);
|
|
272
283
|
}
|
|
@@ -282,12 +293,13 @@ export async function produceConceptVideos(o) {
|
|
|
282
293
|
// 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
|
|
283
294
|
const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
|
|
284
295
|
const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
|
|
285
|
-
const
|
|
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) });
|
|
286
298
|
const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
|
|
287
|
-
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : speakingBoard ? 0.
|
|
299
|
+
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0.28 : speakingBoard ? 0.45 : 0.6 });
|
|
288
300
|
fs.copyFileSync(mixed, rawFile);
|
|
289
301
|
costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
|
|
290
|
-
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리
|
|
302
|
+
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 크게 낮춤 · 말하는 동안 사이드체인)' : ' (원본 음악 유지 · 말하는 동안만 낮춤)'}`);
|
|
291
303
|
}
|
|
292
304
|
catch (e) {
|
|
293
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
|
-
|
|
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}
|
|
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)에 맞는 연출 컷
|
|
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: '
|
|
13
|
-
lively: { camera: 'energetic handheld feel, quick push-ins,
|
|
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'], ['노선·좌석·서비스', '운임 조건'], ['안전 관련 과장', '경쟁사 언급'], '
|
|
27
|
-
realestate_construction: P('realestate_construction', '건설·부동산·인테리어', ['cinematic', 'text_hook', 'lifestyle'], ['완공 드론 → 내부 워크스루', '「이 동네에 이런 집?」', '시공 전→후(주거 허용·정직하게)'], ['photoreal', '3d'], ['실제 조감·평면·위치', '시공 품질'], ['수익률·시세 상승 단정', '허위 분양 조건'], '
|
|
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'], ['누구의 어떤 문제를 푸는지', '결과물·레퍼런스(공개 가능한 것)'], ['고객사 로고 무단 사용'], '
|
|
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) → 모션그래픽은 추상 도형만, 글자는 우리 자막이 담당
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
@@ -40,8 +40,15 @@ export type I18n = Record<string, LocalizedCopy>;
|
|
|
40
40
|
export declare const langName: (l: string) => string;
|
|
41
41
|
/** 훅 길이 제한 — 한글·전각 12자(wlen 24) · 라틴은 단어 경계에서 자른다(잘린 단어 금지) */
|
|
42
42
|
export declare function fitHook(t: string): string;
|
|
43
|
-
/**
|
|
44
|
-
export declare
|
|
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): {
|
|
45
52
|
sec: number;
|
|
46
53
|
kind: 'gen' | 'ui_demo';
|
|
47
54
|
}[];
|
|
@@ -3,11 +3,11 @@
|
|
|
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(), continues: z.boolean().optional().nullable() })).min(1).max(
|
|
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
11
|
cast: z.string().optional().nullable(),
|
|
12
12
|
});
|
|
13
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' };
|
|
@@ -28,15 +28,21 @@ export function fitHook(t) {
|
|
|
28
28
|
}
|
|
29
29
|
return out || fit(s, 30);
|
|
30
30
|
}
|
|
31
|
-
/**
|
|
32
|
-
export
|
|
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') {
|
|
33
36
|
if (durationSec > 8)
|
|
34
37
|
return [{ sec: durationSec, kind: 'gen' }];
|
|
35
|
-
if (format === 'ugc_selfie'
|
|
38
|
+
if (format === 'ugc_selfie')
|
|
36
39
|
return [{ sec: 8, kind: 'gen' }];
|
|
37
40
|
if (format === 'ui_demo')
|
|
38
41
|
return hasScreens ? [{ sec: 4, kind: 'gen' }, { sec: 4, kind: 'ui_demo' }] : [{ sec: 8, kind: 'gen' }];
|
|
39
|
-
|
|
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' }));
|
|
40
46
|
}
|
|
41
47
|
export async function generateStoryboards(brief, concept, o) {
|
|
42
48
|
const lang = o.lang || brief.language;
|
|
@@ -44,7 +50,8 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
44
50
|
const pb = playbookFor(brief.industry);
|
|
45
51
|
const genres = brief.visualGenres?.length ? brief.visualGenres : pb.genres;
|
|
46
52
|
const tone = TONE_WORDS[o.tone];
|
|
47
|
-
const
|
|
53
|
+
const pace = PACE[o.tone];
|
|
54
|
+
const plans = o.formats.map((f) => ({ format: f, clips: clipPlan(f, o.durationSec, o.hasScreens, o.tone) }));
|
|
48
55
|
if (!aiAvailable())
|
|
49
56
|
return plans.map((p) => fallbackBoard(brief, concept, p.format, p.clips, genres[0], cta, lang, o.durationSec));
|
|
50
57
|
const out = await completeJson({
|
|
@@ -53,12 +60,13 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
53
60
|
업종 플레이북(${pb.label}): 포맷 우선 ${pb.formats.join('·')} · 훅 패턴 ${pb.hooks.join(' / ')} · 꼭 보일 것 ${pb.musts.join('·')} · 피할 것 ${pb.avoid.join('·')}.
|
|
54
61
|
비주얼 장르(우선순위): ${genres.join(' > ')} — ${genres.map((g) => `${g}=${genreStyle(g)}`).join(' | ')}. 애니(anime)는 브랜드가 애니·게임·웹툰일 때만 쓴다.
|
|
55
62
|
톤: ${o.tone} → 카메라 「${tone.camera}」 · 음악 「${tone.music}」 · 자막 ${tone.caption}.
|
|
63
|
+
템포(중요 · 광고는 느긋하면 넘겨진다): ${pace.words}. 장면 컷이 3개면 「훅(문제·놀람) → 전개(반전·행동) → 보상(결과·제품)」 순서로 이야기가 앞으로 달린다. 금지: 풍경만 천천히 흐르는 컷 · 가만히 서서 웃기만 하는 인물 · 로고·앱 아이콘만 떠 있는 컷 · 느린 페이드. 음악은 컷마다 비트가 떨어지는 빠른 곡 · 전환에 whoosh/impact 효과음.
|
|
56
64
|
포맷 정의: ${o.formats.map((f) => `${f}=${FORMAT_DESC[f]}`).join(' | ')}.
|
|
57
65
|
클립 구성(포맷별 고정 · 초 수를 지켜라): ${plans.map((p) => `${p.format}: ${p.clips.map((c) => `${c.sec}s ${c.kind}`).join(' + ')}`).join(' / ')}.
|
|
58
66
|
규칙:
|
|
59
|
-
- boards 는 요청 포맷마다 정확히 1개. 각 board: format · genre(위 장르 중 하나) · hook(첫 프레임 큰 글자 · ${lang} · 6단어/12자 이내 · 질문/숫자/반전) · captions(화면 자막 3줄 · ${lang} · 각 18자 이내 · 훅→가치/증명→행동 순 · 마지막 줄은 행동 유도) · voice(선택 · ${lang}
|
|
60
|
-
- clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이
|
|
61
|
-
- ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — 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>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
|
|
62
70
|
- ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
|
|
63
71
|
- text_hook 포맷의 clips[].prompt 는 「자막 뒤에 깔릴 배경 장면」이다 — 타겟이 일하는/사용하는 실사 장면(사람 뒷모습·손·사무실·제품 사용) 또는 추상 도형. 화면 안에 글자·숫자·차트 라벨·UI 를 그리지 말 것(우리 자막이 글자를 담당 · AI 글자는 깨진다).
|
|
64
72
|
- 사람이 나오는 board 는 cast 에 인물 외형 고정 문장(영어 1문장 · 성별/나이대/인종·피부톤/머리/옷/소품 · 브리프 타겟에 맞게)을 쓰고, 그 인물이 이어지는 컷은 clips[].continues=true(앞 컷의 마지막 프레임에서 이어짐 · 인물·장소 동일). 인물 없는 컷은 continues=false.
|
|
@@ -82,7 +90,7 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
82
90
|
function fallbackBoard(brief, concept, format, plan, genre, cta, lang, durationSec) {
|
|
83
91
|
const style = genreStyle(genre);
|
|
84
92
|
const base = concept.videoPrompt || `A short advertisement scene inspired by: ${concept.imagePrompt}.`;
|
|
85
|
-
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.` });
|
|
86
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 };
|
|
87
95
|
}
|
|
88
96
|
/** 채점 — 훅 강도·3초 명료성·정책 위험·브랜드 적합·타겟 적합(각 0~10) → 평균. 정책 위험은 규칙 검사(policyIssues)로 먼저 깎는다. */
|
|
@@ -123,7 +131,7 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
123
131
|
}
|
|
124
132
|
const r = await completeJson({
|
|
125
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,
|
|
126
|
-
system: `너는 광고 현지화 카피라이터다. 아래 문구·자막·훅·대사를 각 언어로 옮긴다. 직역이 아니라 그 시장 광고에서 자연스러운 표현으로, 뜻·사실·숫자는 그대로. 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자(한글·전각 2자) · 설명 ≤ ${GOOGLE_LIMITS.description}자 · 자막 각 18자(영문 32자) 이내 · 훅 6단어 이내 · 대사(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}]}}}`,
|
|
127
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 })) }),
|
|
128
136
|
});
|
|
129
137
|
// 키 정규화 — 모델이 "ja (Japanese)"·"Japanese"·"JA" 로 돌려주기도 함
|
|
@@ -140,7 +148,7 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
140
148
|
if (!t) {
|
|
141
149
|
// 한 언어만 다시(누락 보정) · 그래도 없으면 원문으로 채워 소재 생성은 막지 않는다
|
|
142
150
|
try {
|
|
143
|
-
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자) · 대사는
|
|
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 })) }) });
|
|
144
152
|
t = one;
|
|
145
153
|
}
|
|
146
154
|
catch {
|
|
@@ -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
|
-
|
|
14
|
-
const
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|
|
@@ -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
|
-
|
|
100
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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% 폭으로 · 실제 제품이 그대로 움직이게 */
|
package/package.json
CHANGED