adyou 0.6.4 → 0.6.6
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 +31 -12
- 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 +30 -7
- package/dist/core/creatives/speech.d.ts +8 -0
- package/dist/core/creatives/speech.js +119 -0
- package/dist/core/creatives/storyboard.d.ts +9 -2
- package/dist/core/creatives/storyboard.js +24 -14
- package/dist/core/creatives/tts.d.ts +3 -1
- package/dist/core/creatives/tts.js +20 -4
- package/dist/core/creatives/videofx.d.ts +10 -2
- package/dist/core/creatives/videofx.js +35 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -5,12 +5,13 @@
|
|
|
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
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
|
+
import { spokenForm } from './speech.js';
|
|
14
15
|
import { isMockGen } from './images.js';
|
|
15
16
|
import { checkArtifacts, checkFrames, checkProductMatch, checkSpeech } from './qa.js';
|
|
16
17
|
/** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
|
|
@@ -58,6 +59,7 @@ export async function produceConceptVideos(o) {
|
|
|
58
59
|
log(' ⚠ ffmpeg 가 없어 자막·엔드카드·비율 파생 없이 원본 영상만 써요');
|
|
59
60
|
const tone = o.tone || 'lively';
|
|
60
61
|
const tw = TONE_WORDS[tone];
|
|
62
|
+
const pace = PACE[tone];
|
|
61
63
|
const baseLang = o.brief.language || 'ko';
|
|
62
64
|
// 시장 언어만 만든다(브리프 언어가 시장에 없으면 번역 원문으로만 쓰고 그 언어 영상은 만들지 않음)
|
|
63
65
|
const langs = o.langs?.length ? [...new Set(o.langs)] : [baseLang];
|
|
@@ -124,9 +126,16 @@ export async function produceConceptVideos(o) {
|
|
|
124
126
|
const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
|
|
125
127
|
const mode = line ? speechModeFor(rl) : 'none';
|
|
126
128
|
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
127
|
-
|
|
129
|
+
// 2026-09-17 사장님 「말이 너무 느리다 · 빨리 치면 대사 더 들어가도 된다」 → 신난 친구 톤으로 빠르게 · 마지막 1초만 무언
|
|
130
|
+
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: "${spokenForm(line, rl, [o.brief.company])}". 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
131
|
const castLine = board.cast ? ` Character (identical in every shot — same face, hair, skin tone, outfit): ${board.cast}.` : '';
|
|
129
|
-
|
|
132
|
+
// 장면 컷(말하지 않는)은 빠른 템포 명시 — 느긋한 풍경·정지 인물 금지(이어붙일 때 ${pace.speed}배속도 함)
|
|
133
|
+
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.` : '';
|
|
134
|
+
// 내레이션이 얹히는 장면 컷에 사람이 카메라를 보며 말하는 듯 서 있으면 「소리는 나는데 입은 안 움직이는」 영상이 된다(티르티르 라이프스타일 · 2026-09-17) → 관찰 시점 · 아무도 카메라에 말하지 않음
|
|
135
|
+
const candidLine = !line && SCENE_FORMATS.includes(format) && format !== 'product_hero' ? ` Observational footage: nobody addresses the camera or talks — if a person is visible they are absorbed in what they are doing (using the product, moving, reacting to a friend off-screen), lips closed or a natural smile, never looking into the lens like a presenter. A narrator voice-over is added later.` : '';
|
|
136
|
+
// 제품 히어로: 제품이 물리적으로 그대로 — 없던 부품(어플리케이터·브러시)이 생기거나 열리지 않음(티르티르 「바르는 부분이 없다가 생김」 · 2026-09-17)
|
|
137
|
+
const productLine = format === 'product_hero' ? ` The product stays physically exactly as it is: nothing new appears on it, no parts detach, open, extend or transform (no applicator or brush emerging from a closed product); if the product is shown open, it is open from the very first frame. Motion comes from camera, light, rotation, liquid or hands presenting it.` : '';
|
|
138
|
+
const prompt = `${clip.prompt}${castLine}${speech}${paceLine}${candidLine}${productLine} 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
139
|
// 첫 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) — 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 첫 프레임으로. 그 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
|
|
131
140
|
let firstFrame;
|
|
132
141
|
const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
|
|
@@ -151,8 +160,10 @@ export async function produceConceptVideos(o) {
|
|
|
151
160
|
if (clipModel !== job.model)
|
|
152
161
|
log(` ${langName(rl)} 대사는 Seedance 2.5 네이티브 발화로(립싱크)`);
|
|
153
162
|
let r;
|
|
163
|
+
// 참고 사진은 실사 장르에만 — 애니·3D·일러스트·모션그래픽 컷에 사진(특히 로고)을 참고로 넣으면 세계관 대신 로고가 흐른다(ISEKAI'D 실사고 · 2026-09-17)
|
|
164
|
+
const photoGenre = !['anime', '3d', 'illustration', 'motion_graphics'].includes(board.genre);
|
|
154
165
|
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 });
|
|
166
|
+
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
167
|
}
|
|
157
168
|
catch (e) {
|
|
158
169
|
if (!e.retryable)
|
|
@@ -186,12 +197,12 @@ export async function produceConceptVideos(o) {
|
|
|
186
197
|
log(` ${q.note}`);
|
|
187
198
|
if (!q.ok && ttsAvailable()) {
|
|
188
199
|
try {
|
|
189
|
-
const v = await synthesizeSpeech({ text: line, lang: rl, file: path.join(o.dir, `${key}_${rl}_c${ci}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
|
|
200
|
+
const v = await synthesizeSpeech({ text: line, lang: rl, file: path.join(o.dir, `${key}_${rl}_c${ci}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona, keep: [o.brief.company] });
|
|
190
201
|
const mixed = cf.replace(/\.mp4$/, '.vo.mp4');
|
|
191
|
-
await mixVoiceover(cf, v.file, mixed, {
|
|
202
|
+
await mixVoiceover(cf, v.file, mixed, { replace: true });
|
|
192
203
|
fs.copyFileSync(mixed, cf);
|
|
193
204
|
costKrw += v.costKrw;
|
|
194
|
-
log(' → TTS 내레이션으로 대체 ✓ (입모양은 안 맞을 수 있어요 · 자막 중심)');
|
|
205
|
+
log(' → TTS 내레이션으로 대체 ✓ (원본 대사는 소리를 없앰 · 입모양은 안 맞을 수 있어요 · 자막 중심)');
|
|
195
206
|
}
|
|
196
207
|
catch (e) {
|
|
197
208
|
log(` ⚠ TTS 대체 실패: ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
@@ -264,9 +275,14 @@ export async function produceConceptVideos(o) {
|
|
|
264
275
|
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
276
|
continue;
|
|
266
277
|
}
|
|
278
|
+
// 말하지 않는 장면 포맷은 톤 템포만큼 배속해 붙인다(Veo 는 느긋 · 1.25~1.35배가 「보통」으로 보임 · 말하는 컷은 원속도)
|
|
279
|
+
const speed = speaks || !SCENE_FORMATS.includes(format) ? 1 : pace.speed;
|
|
267
280
|
try {
|
|
268
|
-
if (ff)
|
|
269
|
-
await concatClips(clipFiles, rawFile, dims);
|
|
281
|
+
if (ff) {
|
|
282
|
+
await concatClips(clipFiles, rawFile, dims, { speed });
|
|
283
|
+
if (speed !== 1)
|
|
284
|
+
log(` ${clipFiles.length}컷 하드컷 이어붙임 · ${speed}배속(빠른 템포)`);
|
|
285
|
+
}
|
|
270
286
|
else
|
|
271
287
|
fs.copyFileSync(clipFiles[0], rawFile);
|
|
272
288
|
}
|
|
@@ -282,12 +298,15 @@ export async function produceConceptVideos(o) {
|
|
|
282
298
|
// 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
|
|
283
299
|
const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
|
|
284
300
|
const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
|
|
285
|
-
const
|
|
301
|
+
const rawSec = (await probe(rawFile).catch(() => ({ durationSec: job.durationSec }))).durationSec;
|
|
302
|
+
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), keep: [o.brief.company] });
|
|
286
303
|
const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
|
|
287
|
-
|
|
304
|
+
// 말하는 포맷(UGC)인데 원본에 말소리가 있으면(다른 언어·웅얼거림) 겹쳐 들리므로 원본 소리를 버린다 · 장면 포맷은 음악을 남기고 크게 낮춤
|
|
305
|
+
const replace = hasSpeech && speakingBoard;
|
|
306
|
+
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0.2 : speakingBoard ? 0.45 : 0.6, replace });
|
|
288
307
|
fs.copyFileSync(mixed, rawFile);
|
|
289
308
|
costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
|
|
290
|
-
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리
|
|
309
|
+
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${replace ? ' (원본 말소리 감지 → 원본 소리 없앰)' : hasSpeech ? ' (원본 말소리 감지 → 원본 소리 크게 낮춤 · 말하는 동안 사이드체인)' : ' (원본 음악 유지 · 말하는 동안만 낮춤)'}`);
|
|
291
310
|
}
|
|
292
311
|
catch (e) {
|
|
293
312
|
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;
|
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import satori from 'satori';
|
|
7
7
|
import { Resvg } from '@resvg/resvg-js';
|
|
8
8
|
import { HOME } from '../state.js';
|
|
9
|
-
import { SIZES } from './specs.js';
|
|
9
|
+
import { SIZES, wlen } from './specs.js';
|
|
10
10
|
const FONT_CDN = 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard/dist/public/static';
|
|
11
11
|
const WEIGHTS = [['Pretendard-ExtraBold', 800], ['Pretendard-Bold', 700], ['Pretendard-Medium', 500]];
|
|
12
12
|
/** 언어별 글꼴 폴백 — Pretendard 는 한글·라틴만. 일본어=Pretendard JP(가나·한자) · 중국어=Noto Sans TC/SC · 태국어=Noto Sans Thai. 같은 family 이름으로 넣어 satori 가 빠진 글리프를 다음 글꼴에서 찾게 한다. */
|
|
@@ -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,21 @@ 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
|
-
|
|
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
|
+
let 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
|
+
let bodyFs = short ? 0 : Math.round((narrow ? w * 0.085 : banner ? base * 0.062 : googleLight ? base * 0.036 : base * 0.046) * wideBoost);
|
|
146
|
+
let ctaFs = Math.round((short ? hh * 0.28 : narrow ? w * 0.09 : banner ? base * 0.06 : googleLight ? base * 0.036 : base * 0.04) * wideBoost);
|
|
147
|
+
// 🔴 가로 포스터(1200×628 · 16:9 엔드카드)는 v2.7 의 1.35배로도 「글자가 작고 양이 적다」(2026-09-17 사장님 재검수) → 높이 기준으로 크게(헤드라인 15.5% · 본문 5.6% · CTA 5.8%) + 본문 두 줄 + 증거 알약 최대 3개
|
|
148
|
+
const widePoster = !banner && w / hh > 1.6;
|
|
149
|
+
if (widePoster) {
|
|
150
|
+
headFs = Math.round(hh * (wlen(head) > 24 ? 0.125 : 0.155));
|
|
151
|
+
bodyFs = Math.round(hh * 0.056);
|
|
152
|
+
ctaFs = Math.round(hh * 0.058);
|
|
153
|
+
}
|
|
154
|
+
const chipFs = Math.round(hh * 0.044);
|
|
155
|
+
const proofs = widePoster ? [...(brief.proofPoints || []), ...(brief.usp || [])].map((x) => cutWords(String(x || '').replace(/\s+/g, ' ').trim(), 18)).filter((x) => x && wlen(x) <= 36).filter((x, i, a) => a.indexOf(x) === i).slice(0, 3) : [];
|
|
156
|
+
const chips = proofs.length ? h('div', { display: 'flex', flexWrap: 'wrap', gap: Math.round(chipFs * 0.45) }, proofs.map((t) => h('div', { display: 'flex', alignItems: 'center', gap: Math.round(chipFs * 0.4), fontSize: chipFs, fontWeight: 700, color: fg, backgroundColor: dark ? 'rgba(255,255,255,.16)' : 'rgba(255,255,255,.78)', borderRadius: 999, padding: `${Math.round(chipFs * 0.28)}px ${Math.round(chipFs * 0.7)}px`, whiteSpace: 'nowrap' }, [h('div', { width: Math.round(chipFs * 0.42), height: Math.round(chipFs * 0.42), borderRadius: 999, backgroundColor: primary }), h('div', { whiteSpace: 'nowrap' }, t)]))) : null;
|
|
136
157
|
const children = [];
|
|
137
158
|
if (bg)
|
|
138
159
|
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 +167,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
146
167
|
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
168
|
}
|
|
148
169
|
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
|
|
170
|
+
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
171
|
}
|
|
151
172
|
else {
|
|
152
173
|
const isWide = w / hh > 1.6;
|
|
@@ -154,7 +175,8 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
154
175
|
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
176
|
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
177
|
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
|
|
178
|
+
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(banner ? cutWords(body, 70) : widePoster ? cutWords(body, 60) : googleLight ? cutWords(body, 48) : body, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }),
|
|
179
|
+
...(chips ? [chips] : []),
|
|
158
180
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
159
181
|
]),
|
|
160
182
|
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 +233,8 @@ export async function renderLogo(dir, brief) {
|
|
|
211
233
|
/** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
|
|
212
234
|
export async function renderOverlay(file, w, hh, text, o) {
|
|
213
235
|
const fonts = await loadFonts(o.lang);
|
|
214
|
-
|
|
236
|
+
// 가로 영상(16:9)은 짧은 변 기준 글자가 폭 대비 작아 보임 → 1.35배(2026-09-17)
|
|
237
|
+
const base = Math.min(w, hh) * (w / hh >= 1.3 ? 1.35 : 1);
|
|
215
238
|
const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
|
|
216
239
|
const pill = o.kind === 'cta'
|
|
217
240
|
? 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)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 한자어 수사 — 82700 → 「팔만 이천칠백」 · 1500 → 「천오백」 · 10000 → 「만」 */
|
|
2
|
+
export declare function koNumberWords(n: number): string;
|
|
3
|
+
/** 고유어 수사(단위 앞 · 1~99) — 3개 → 「세 개」 · 12개 → 「열두 개」 · 20개 → 「스무 개」 */
|
|
4
|
+
export declare function koNativeCount(n: number): string | null;
|
|
5
|
+
/** 일본어 한자 숫자 — 82700 → 「八万二千七百」 · 10000 → 「一万」 · 1000 → 「千」 */
|
|
6
|
+
export declare function jaNumberWords(n: number): string;
|
|
7
|
+
/** 말하기용 문장 — 자막은 원문 그대로 두고, TTS·발화 프롬프트에만 쓴다. keep = 브랜드명 등 대문자를 유지할 낱말 */
|
|
8
|
+
export declare function spokenForm(text: string, lang: string, keep?: string[]): string;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// 말로 읽히는 형태(spoken form) — TTS 와 네이티브 발화 모델은 「글자 그대로」 읽는다.
|
|
2
|
+
// 2026-09-17 사장님 검수: 에어서울 TTS 가 「82,700원」을 「82만 7천원」으로 · ISEKAI'D 영어 UGC 가 강조 대문자 「ME」를 「엠-이」로 읽음.
|
|
3
|
+
// 자막·포스터에는 원문(숫자·대문자)을 그대로 쓰고, 말하는 데 넘기는 문장만 이 함수로 바꾼다.
|
|
4
|
+
const KO_DIGIT = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'];
|
|
5
|
+
const KO_SMALL = ['', '십', '백', '천'];
|
|
6
|
+
const KO_BIG = ['', '만', '억', '조', '경'];
|
|
7
|
+
/** 한자어 수사 — 82700 → 「팔만 이천칠백」 · 1500 → 「천오백」 · 10000 → 「만」 */
|
|
8
|
+
export function koNumberWords(n) {
|
|
9
|
+
if (!Number.isFinite(n) || n < 0)
|
|
10
|
+
return String(n);
|
|
11
|
+
if (n === 0)
|
|
12
|
+
return '영';
|
|
13
|
+
const groups = [];
|
|
14
|
+
let v = Math.floor(n);
|
|
15
|
+
let gi = 0;
|
|
16
|
+
while (v > 0 && gi < KO_BIG.length) {
|
|
17
|
+
const g = v % 10000;
|
|
18
|
+
if (g) {
|
|
19
|
+
let s = '';
|
|
20
|
+
for (let p = 3; p >= 0; p--) {
|
|
21
|
+
const d = Math.floor(g / 10 ** p) % 10;
|
|
22
|
+
if (!d)
|
|
23
|
+
continue;
|
|
24
|
+
s += (d === 1 && p > 0 ? '' : KO_DIGIT[d]) + KO_SMALL[p];
|
|
25
|
+
}
|
|
26
|
+
// 「일만」 → 「만」 · 「일억」 은 그대로
|
|
27
|
+
if (gi === 1 && s === '일')
|
|
28
|
+
s = '';
|
|
29
|
+
groups.unshift(s + KO_BIG[gi]);
|
|
30
|
+
}
|
|
31
|
+
v = Math.floor(v / 10000);
|
|
32
|
+
gi++;
|
|
33
|
+
}
|
|
34
|
+
return groups.join(' ');
|
|
35
|
+
}
|
|
36
|
+
const KO_NATIVE_1 = ['', '한', '두', '세', '네', '다섯', '여섯', '일곱', '여덟', '아홉'];
|
|
37
|
+
const KO_NATIVE_10 = ['', '열', '스무', '서른', '마흔', '쉰', '예순', '일흔', '여든', '아흔'];
|
|
38
|
+
/** 고유어 수사(단위 앞 · 1~99) — 3개 → 「세 개」 · 12개 → 「열두 개」 · 20개 → 「스무 개」 */
|
|
39
|
+
export function koNativeCount(n) {
|
|
40
|
+
if (!Number.isInteger(n) || n < 1 || n > 99)
|
|
41
|
+
return null;
|
|
42
|
+
const t = Math.floor(n / 10), u = n % 10;
|
|
43
|
+
if (t === 2 && u > 0)
|
|
44
|
+
return '스물' + KO_NATIVE_1[u];
|
|
45
|
+
return (t ? KO_NATIVE_10[t] : '') + (u ? KO_NATIVE_1[u] : '');
|
|
46
|
+
}
|
|
47
|
+
/** 고유어로 세는 단위(뒤에 바로 붙는 것) */
|
|
48
|
+
const KO_NATIVE_UNITS = ['개', '명', '번', '잔', '장', '병', '시간', '살', '마리', '군데', '가지', '곳', '달', '배', '대', '벌', '켤레', '봉지', '판', '컷', '줄', '분(?=\\s|$)'];
|
|
49
|
+
const JA_DIGIT = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
|
|
50
|
+
const JA_SMALL = ['', '十', '百', '千'];
|
|
51
|
+
const JA_BIG = ['', '万', '億', '兆'];
|
|
52
|
+
/** 일본어 한자 숫자 — 82700 → 「八万二千七百」 · 10000 → 「一万」 · 1000 → 「千」 */
|
|
53
|
+
export function jaNumberWords(n) {
|
|
54
|
+
if (!Number.isFinite(n) || n < 0)
|
|
55
|
+
return String(n);
|
|
56
|
+
if (n === 0)
|
|
57
|
+
return '零';
|
|
58
|
+
const groups = [];
|
|
59
|
+
let v = Math.floor(n);
|
|
60
|
+
let gi = 0;
|
|
61
|
+
while (v > 0 && gi < JA_BIG.length) {
|
|
62
|
+
const g = v % 10000;
|
|
63
|
+
if (g) {
|
|
64
|
+
let s = '';
|
|
65
|
+
for (let p = 3; p >= 0; p--) {
|
|
66
|
+
const d = Math.floor(g / 10 ** p) % 10;
|
|
67
|
+
if (!d)
|
|
68
|
+
continue;
|
|
69
|
+
s += (d === 1 && p > 0 && p < 3 ? '' : d === 1 && p === 3 && gi === 0 ? '' : JA_DIGIT[d]) + JA_SMALL[p];
|
|
70
|
+
}
|
|
71
|
+
if (gi >= 1 && s === '')
|
|
72
|
+
s = '一';
|
|
73
|
+
groups.unshift(s + JA_BIG[gi]);
|
|
74
|
+
}
|
|
75
|
+
v = Math.floor(v / 10000);
|
|
76
|
+
gi++;
|
|
77
|
+
}
|
|
78
|
+
return groups.join('');
|
|
79
|
+
}
|
|
80
|
+
/** 영어 대사에서 그대로 두는 약어(실제로 글자 단위로 읽는 것) */
|
|
81
|
+
const EN_ACRONYMS = new Set(['AI', 'VR', 'AR', 'XR', 'UI', 'UX', 'PC', 'TV', 'DIY', 'USA', 'UK', 'EU', 'NYC', 'LA', 'FAQ', 'ID', 'OK', 'DM', 'CEO', 'CTO', 'HR', 'IT', 'SEO', 'B2B', 'B2C', 'SaaS', '3D', '2D', '4K', 'HD', 'TTS', 'API', 'CRM', 'ERP', 'POS', 'GPS', 'USB', 'LED', 'SUV', 'EV', 'NFT', 'RPG', 'MMO', 'FPS', 'PDF', 'URL', 'VIP', 'ASAP', 'BTS', 'KPOP', 'K']);
|
|
82
|
+
/** 영어 구어에서 글자로 읽으면 안 되는 약어 → 풀어 쓴 말 */
|
|
83
|
+
const EN_EXPAND = { MC: 'main character', OP: 'overpowered', IRL: 'in real life', TBH: 'to be honest', OMG: 'oh my god', BFF: 'best friend', DMs: 'direct messages', GOAT: 'greatest of all time', FOMO: 'fear of missing out', POV: 'point of view', ATM: 'right now' };
|
|
84
|
+
const NUM_RE = /(?<![\d.:/-])(\d{1,3}(?:,\d{3})+|\d+)(?![\d.:/-])/g;
|
|
85
|
+
/** 말하기용 문장 — 자막은 원문 그대로 두고, TTS·발화 프롬프트에만 쓴다. keep = 브랜드명 등 대문자를 유지할 낱말 */
|
|
86
|
+
export function spokenForm(text, lang, keep = []) {
|
|
87
|
+
const keepSet = new Set(keep.flatMap((k) => String(k || '').split(/[^A-Za-z0-9]+/)).filter(Boolean));
|
|
88
|
+
const L = (lang || 'en').split('-')[0].toLowerCase();
|
|
89
|
+
let s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
90
|
+
if (!s)
|
|
91
|
+
return s;
|
|
92
|
+
if (L === 'ko') {
|
|
93
|
+
s = s.replace(/(\d[\d,]*[가-힣]*)\s*[~∼~]\s*(?=\d)/g, '$1에서 ');
|
|
94
|
+
s = s.replace(/%/g, '퍼센트').replace(/\s*&\s*/g, ' 앤 ').replace(/\+/g, ' 플러스 ');
|
|
95
|
+
// 고유어 단위(개·명·번…) 앞의 1~99 는 고유어로
|
|
96
|
+
s = s.replace(new RegExp(`(?<![\\d,.])(\\d{1,2})(?=(?:${KO_NATIVE_UNITS.join('|')}))`, 'g'), (m) => { const w = koNativeCount(Number(m)); return w ? `${w} ` : m; });
|
|
97
|
+
s = s.replace(NUM_RE, (m) => koNumberWords(Number(m.replace(/,/g, ''))));
|
|
98
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
99
|
+
}
|
|
100
|
+
if (L === 'ja') {
|
|
101
|
+
s = s.replace(/(\d[\d,]*[-ヿ一-鿿]*)\s*[~∼~]\s*(?=\d)/g, '$1から');
|
|
102
|
+
s = s.replace(/%/g, 'パーセント').replace(/\s*&\s*/g, 'と');
|
|
103
|
+
s = s.replace(NUM_RE, (m) => jaNumberWords(Number(m.replace(/,/g, ''))));
|
|
104
|
+
return s.trim();
|
|
105
|
+
}
|
|
106
|
+
if (L === 'en') {
|
|
107
|
+
s = s.replace(/\s*&\s*/g, ' and ').replace(/(\S)\s*=\s*(\S)/g, '$1 equals $2').replace(/\bw\//g, 'with').replace(/\bvs\.?\b/gi, 'versus');
|
|
108
|
+
s = s.replace(/\b([A-Za-z]+)\b/g, (w) => {
|
|
109
|
+
if (EN_EXPAND[w])
|
|
110
|
+
return EN_EXPAND[w];
|
|
111
|
+
// 강조용 대문자 낱말(ME · BE · SO · LITERALLY)은 소문자로 — 모델이 약어로 보고 한 글자씩 읽는다. 진짜 약어·한 글자(I·A)는 그대로
|
|
112
|
+
if (/^[A-Z]{2,}$/.test(w) && !EN_ACRONYMS.has(w) && !keepSet.has(w))
|
|
113
|
+
return w.toLowerCase();
|
|
114
|
+
return w;
|
|
115
|
+
});
|
|
116
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
@@ -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,16 +60,19 @@ 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.
|
|
65
|
-
- 제품은 물리적으로 말이 되게 — 실제 제품 종류 하나만(쿠션에서 마스카라 브러시가 나오는 식 금지) · 한 컷에 한 제품 · 손과 제품 비율
|
|
73
|
+
- 제품은 물리적으로 말이 되게 — 실제 제품 종류 하나만(쿠션에서 마스카라 브러시가 나오는 식 금지) · 한 컷에 한 제품 · 손과 제품 비율 자연스럽게 · **컷 도중에 없던 부품(어플리케이터·브러시·뚜껑)이 생기거나 제품이 열리는 연출 금지** — 열린 상태를 보이려면 첫 프레임부터 열려 있게.
|
|
74
|
+
- 말하지 않는 장면 포맷(cinematic·lifestyle·text_hook)의 사람은 **카메라를 보며 말하는 듯 서 있지 않는다** — 내레이션(voice)이 따로 얹히므로 인물은 행동 중(제품 사용·이동·친구와 반응)이고 렌즈를 보지 않는다(입 다문 채 카메라 응시 = 「소리는 나는데 입이 안 움직이는」 영상).
|
|
75
|
+
- speech·voice 는 **입으로 읽히는 그대로** 쓴다: 강조용 대문자(ME·BE·SO)·약어(MC·IRL·OP)·기호(=·&·+·%)·이모지 금지 — 발화 모델이 글자 단위로 읽는다. 숫자는 그대로 써도 된다(읽기 형태는 우리가 바꾼다).
|
|
66
76
|
- 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
|
|
67
77
|
- 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,cast,clips:[{sec,kind,prompt,speech,continues}]}]}`,
|
|
68
78
|
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 }),
|
|
@@ -82,7 +92,7 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
82
92
|
function fallbackBoard(brief, concept, format, plan, genre, cta, lang, durationSec) {
|
|
83
93
|
const style = genreStyle(genre);
|
|
84
94
|
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.` });
|
|
95
|
+
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
96
|
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
97
|
}
|
|
88
98
|
/** 채점 — 훅 강도·3초 명료성·정책 위험·브랜드 적합·타겟 적합(각 0~10) → 평균. 정책 위험은 규칙 검사(policyIssues)로 먼저 깎는다. */
|
|
@@ -123,7 +133,7 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
123
133
|
}
|
|
124
134
|
const r = await completeJson({
|
|
125
135
|
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)는 말하면
|
|
136
|
+
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
137
|
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
138
|
});
|
|
129
139
|
// 키 정규화 — 모델이 "ja (Japanese)"·"Japanese"·"JA" 로 돌려주기도 함
|
|
@@ -140,7 +150,7 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
140
150
|
if (!t) {
|
|
141
151
|
// 한 언어만 다시(누락 보정) · 그래도 없으면 원문으로 채워 소재 생성은 막지 않는다
|
|
142
152
|
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자) · 대사는
|
|
153
|
+
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
154
|
t = one;
|
|
145
155
|
}
|
|
146
156
|
catch {
|
|
@@ -10,5 +10,7 @@ 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; /** 대문자를 유지할 낱말(브랜드명) */
|
|
15
|
+
keep?: string[];
|
|
14
16
|
}): Promise<TtsResult>;
|
|
@@ -1,7 +1,11 @@
|
|
|
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';
|
|
8
|
+
import { spokenForm } from './speech.js';
|
|
5
9
|
const VOICE = { ko: 'nova', ja: 'shimmer', en: 'nova', zh: 'nova', default: 'alloy' };
|
|
6
10
|
export function ttsAvailable() { const c = imageConfig(); return !!c && c.provider === 'bizrouter'; }
|
|
7
11
|
/** 한 문장 내레이션 mp3 — 톤은 광고 UGC(캐주얼·자연스럽게). 실패는 예외. 원가는 응답에 없어 추정(≈₩15/문장). */
|
|
@@ -10,12 +14,24 @@ export async function synthesizeSpeech(o) {
|
|
|
10
14
|
if (!cfg || cfg.provider !== 'bizrouter')
|
|
11
15
|
throw Object.assign(new Error('TTS 키(BizRouter)가 없어요'), { fatal: true });
|
|
12
16
|
const lang = o.lang.split('-')[0];
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
17
|
+
// 2026-09-17 사장님 「말이 너무 느리다」 → 기본 빠른 템포(신난 친구가 소식 전하듯) · calm 도 처지지 않게
|
|
18
|
+
const style = o.tone === 'calm' ? 'warm, trustworthy, brisk but unhurried' : o.tone === 'bold' ? 'energetic, confident, punchy, fast' : 'casual, upbeat, excited, fast';
|
|
19
|
+
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}.` : ''}`;
|
|
20
|
+
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: spokenForm(o.text, lang, o.keep), voice: VOICE[lang] || VOICE.default, instructions, response_format: 'mp3' }), signal: AbortSignal.timeout(60_000) });
|
|
16
21
|
if (!res.ok)
|
|
17
22
|
throw new Error(`TTS ${res.status} ${(await res.text()).slice(0, 160)}`);
|
|
18
23
|
fs.writeFileSync(o.file, Buffer.from(await res.arrayBuffer()));
|
|
19
|
-
|
|
24
|
+
let p = await probe(o.file).catch(() => ({ durationSec: 0 }));
|
|
25
|
+
if (o.fitSec && p.durationSec > o.fitSec + 0.05) {
|
|
26
|
+
const tempo = Math.min(1.4, p.durationSec / o.fitSec);
|
|
27
|
+
const fast = o.file.replace(/\.mp3$/, '.fast.mp3');
|
|
28
|
+
try {
|
|
29
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', o.file, '-af', `atempo=${tempo.toFixed(3)}`, '-c:a', 'libmp3lame', '-q:a', '3', fast]);
|
|
30
|
+
fs.copyFileSync(fast, o.file);
|
|
31
|
+
fs.rmSync(fast, { force: true });
|
|
32
|
+
p = await probe(o.file).catch(() => p);
|
|
33
|
+
}
|
|
34
|
+
catch { /* 원본 유지 */ }
|
|
35
|
+
}
|
|
20
36
|
return { file: o.file, durationSec: p.durationSec, costKrw: 15 };
|
|
21
37
|
}
|
|
@@ -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,10 +67,15 @@ 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
|
-
startSec?: number;
|
|
77
|
+
startSec?: number; /** 원본 소리를 완전히 버리고 목소리만(원본에 다른 언어 대사가 있을 때 · 겹쳐 들리면 안 됨 · 2026-09-17 ISEKAI'D 일본어 이중 더빙) */
|
|
78
|
+
replace?: boolean;
|
|
71
79
|
}): Promise<void>;
|
|
72
80
|
/** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
|
|
73
81
|
export declare function refCanvas(ref: string, bgPng: string, output: string, dims: {
|
|
@@ -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,32 @@ 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
|
-
const
|
|
207
|
+
const useBg = p.hasAudio && !o.replace;
|
|
208
|
+
// 원본(음악)은 평균 -20dB 로 먼저 맞춘 뒤(Veo 원본은 -29dB 로 작음) duck 배 · 목소리는 평균 -18dB
|
|
209
|
+
const bgGain = useBg ? await gainToDb(video, -20) : 0;
|
|
210
|
+
const voGain = await gainToDb(voice, -18, -8, 12);
|
|
211
|
+
const fc = useBg
|
|
212
|
+
? `[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]`
|
|
213
|
+
: `[1:a]adelay=${st}|${st},volume=${voGain.toFixed(1)}dB,apad,atrim=0:${p.durationSec.toFixed(2)}[a]`;
|
|
185
214
|
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
215
|
}
|
|
187
216
|
/** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from './core/creatives/playbook.js';
|
|
|
15
15
|
export * from './core/creatives/storyboard.js';
|
|
16
16
|
export * from './core/creatives/siteshots.js';
|
|
17
17
|
export * from './core/creatives/tts.js';
|
|
18
|
+
export * from './core/creatives/speech.js';
|
|
18
19
|
export * from './core/creatives/qa.js';
|
|
19
20
|
export * from './core/creatives/models.js';
|
|
20
21
|
export { computePlan, type PlanInput } from './core/plan.js';
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export * from './core/creatives/playbook.js';
|
|
|
16
16
|
export * from './core/creatives/storyboard.js';
|
|
17
17
|
export * from './core/creatives/siteshots.js';
|
|
18
18
|
export * from './core/creatives/tts.js';
|
|
19
|
+
export * from './core/creatives/speech.js';
|
|
19
20
|
export * from './core/creatives/qa.js';
|
|
20
21
|
export * from './core/creatives/models.js';
|
|
21
22
|
export { computePlan } from './core/plan.js';
|
package/package.json
CHANGED