adyou 0.6.5 → 0.6.7
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 +15 -8
- package/dist/core/creatives/render.js +48 -5
- package/dist/core/creatives/speech.d.ts +8 -0
- package/dist/core/creatives/speech.js +119 -0
- package/dist/core/creatives/storyboard.js +7 -2
- package/dist/core/creatives/tts.d.ts +2 -1
- package/dist/core/creatives/tts.js +2 -1
- package/dist/core/creatives/videofx.d.ts +2 -1
- package/dist/core/creatives/videofx.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import { langName, localizedBoard, localizedConcept, planStoryboards, translateC
|
|
|
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 사장님 검수) */
|
|
@@ -126,11 +127,15 @@ export async function produceConceptVideos(o) {
|
|
|
126
127
|
const mode = line ? speechModeFor(rl) : 'none';
|
|
127
128
|
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
128
129
|
// 2026-09-17 사장님 「말이 너무 느리다 · 빨리 치면 대사 더 들어가도 된다」 → 신난 친구 톤으로 빠르게 · 마지막 1초만 무언
|
|
129
|
-
const speech = line && mode === 'native' ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}), talking QUICKLY and excitedly like a friend sharing great news — fast natural pace, no long pauses, every word clear: "${line}". Natural lip sync, genuine, native-speaker accent. They start speaking immediately at second 0 and finish the whole line by second ${Math.max(4, clip.sec - 1)}; for the final second they are silent, smiling or nodding at the camera (no speech cut-off).` : line ? ` The person does NOT speak at all — lips stay closed or in a natural smile; they react to the camera (nod, raised eyebrows, showing the product or their phone screen, a small laugh) like a silent vlog moment; a narrator voice-over will be added later. Audio: light background music only, no speech, no dialogue, no humming.` : '';
|
|
130
|
+
const 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.` : '';
|
|
130
131
|
const castLine = board.cast ? ` Character (identical in every shot — same face, hair, skin tone, outfit): ${board.cast}.` : '';
|
|
131
132
|
// 장면 컷(말하지 않는)은 빠른 템포 명시 — 느긋한 풍경·정지 인물 금지(이어붙일 때 ${pace.speed}배속도 함)
|
|
132
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.` : '';
|
|
133
|
-
|
|
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.`;
|
|
134
139
|
// 첫 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) — 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 첫 프레임으로. 그 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
|
|
135
140
|
let firstFrame;
|
|
136
141
|
const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
|
|
@@ -192,12 +197,12 @@ export async function produceConceptVideos(o) {
|
|
|
192
197
|
log(` ${q.note}`);
|
|
193
198
|
if (!q.ok && ttsAvailable()) {
|
|
194
199
|
try {
|
|
195
|
-
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] });
|
|
196
201
|
const mixed = cf.replace(/\.mp4$/, '.vo.mp4');
|
|
197
|
-
await mixVoiceover(cf, v.file, mixed, {
|
|
202
|
+
await mixVoiceover(cf, v.file, mixed, { replace: true });
|
|
198
203
|
fs.copyFileSync(mixed, cf);
|
|
199
204
|
costKrw += v.costKrw;
|
|
200
|
-
log(' → TTS 내레이션으로 대체 ✓ (입모양은 안 맞을 수 있어요 · 자막 중심)');
|
|
205
|
+
log(' → TTS 내레이션으로 대체 ✓ (원본 대사는 소리를 없앰 · 입모양은 안 맞을 수 있어요 · 자막 중심)');
|
|
201
206
|
}
|
|
202
207
|
catch (e) {
|
|
203
208
|
log(` ⚠ TTS 대체 실패: ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
@@ -294,12 +299,14 @@ export async function produceConceptVideos(o) {
|
|
|
294
299
|
const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
|
|
295
300
|
const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
|
|
296
301
|
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) });
|
|
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] });
|
|
298
303
|
const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
|
|
299
|
-
|
|
304
|
+
// 말하는 포맷(UGC)인데 원본에 말소리가 있으면(다른 언어·웅얼거림) 겹쳐 들리므로 원본 소리를 버린다 · 장면 포맷은 음악을 남기고 크게 낮춤
|
|
305
|
+
const replace = hasSpeech && speakingBoard;
|
|
306
|
+
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0.2 : speakingBoard ? 0.45 : 0.6, replace });
|
|
300
307
|
fs.copyFileSync(mixed, rawFile);
|
|
301
308
|
costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
|
|
302
|
-
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 크게 낮춤 · 말하는 동안 사이드체인)' : ' (원본 음악 유지 · 말하는 동안만 낮춤)'}`);
|
|
309
|
+
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${replace ? ' (원본 말소리 감지 → 원본 소리 없앰)' : hasSpeech ? ' (원본 말소리 감지 → 원본 소리 크게 낮춤 · 말하는 동안 사이드체인)' : ' (원본 음악 유지 · 말하는 동안만 낮춤)'}`);
|
|
303
310
|
}
|
|
304
311
|
catch (e) {
|
|
305
312
|
log(` ⚠ 내레이션 실패(음악만): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
@@ -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 가 빠진 글리프를 다음 글꼴에서 찾게 한다. */
|
|
@@ -141,9 +141,51 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
141
141
|
// 구글 이미지(1200×628·1200×1200)는 텍스트 20% 안에서 헤드라인 8.5% + 본문 한 줄 3.6% — 너무 비면 썰렁(사장님 검수) · 메타보다 한 단계 작게
|
|
142
142
|
// 🔴 가로로 긴 포스터(1200×628 · 1920×1080)는 짧은 변 기준이라 폭 대비 글자가 절반 크기로 보임(2026-09-17 사장님 「가로 큰 이미지 글자 작다」) → 1.35배 키움(구글 텍스트 20% 안 · 실측 ≈11%)
|
|
143
143
|
const wideBoost = !banner && !short && !narrow && w / hh >= 1.3 ? 1.35 : 1;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
let proofs = widePoster ? [...(brief.proofPoints || []), ...(brief.usp || [])].map((x) => { const t = String(x || '').replace(/\s+/g, ' ').trim(); return cutWords(t, /[\u3000-\u9fff\uac00-\ud7af]/.test(t) ? 18 : 34); }).filter((x) => x && wlen(x) <= 36).filter((x, i, a) => a.indexOf(x) === i).slice(0, 3) : [];
|
|
156
|
+
const cjk = /[\u3000-\u9fff\uac00-\ud7af]/.test(body);
|
|
157
|
+
let bodyText = banner ? cutWords(body, 70) : widePoster ? cutWords(body, cjk ? 60 : 110) : googleLight ? cutWords(body, 48) : body;
|
|
158
|
+
if (widePoster) {
|
|
159
|
+
// 세로 예산 — 헤드라인이 두 줄이면 CTA 가 바닥 밖으로 밀린다(힐스테이트 1200×628 실사고 · 2026-09-17) → 줄 수를 어림해 넘치면 헤드라인 축소 → 알약 줄이기 → 본문 한 줄 순으로 맞춘다
|
|
160
|
+
const colW = Math.round(w * 0.72);
|
|
161
|
+
const gap = Math.round(pad * 0.5);
|
|
162
|
+
// 글자 폭 어림(Pretendard 실측 · 에어서울 97px 헤드라인 780px): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em
|
|
163
|
+
const textW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 : (ch.codePointAt(0) || 0) > 0x2e7f ? 0.86 : 0.55), 0) * fs;
|
|
164
|
+
const lines = (t, fs) => Math.max(1, Math.ceil(textW(t, fs) / colW));
|
|
165
|
+
const chipRows = () => { let rows = 0, x = 0; for (const t of proofs) {
|
|
166
|
+
const cw = textW(t, chipFs) + chipFs * 2.4;
|
|
167
|
+
if (!rows || x + cw > colW) {
|
|
168
|
+
rows++;
|
|
169
|
+
x = cw;
|
|
170
|
+
}
|
|
171
|
+
else
|
|
172
|
+
x += cw + chipFs * 0.45;
|
|
173
|
+
} return rows; };
|
|
174
|
+
// 실측 보정(에어서울 1200×628 · 헤드라인 97 + 본문 2줄 + 알약 2줄 + CTA ≈ 563px 로 들어감) — 로고 행 ≈ headFs×0.5 · 사이 간격 4곳
|
|
175
|
+
const total = () => headFs * 0.5 + lines(head, headFs) * headFs * 1.12 + lines(bodyText, bodyFs) * bodyFs * 1.45 + (proofs.length ? chipRows() * chipFs * 1.9 : 0) + ctaFs * 2 + headFs * 0.28 * 1.4 + gap * 4;
|
|
176
|
+
const avail = hh - pad * 0.9;
|
|
177
|
+
if (total() > avail && chipRows() > 1)
|
|
178
|
+
proofs = proofs.slice(0, 2);
|
|
179
|
+
if (total() > avail && chipRows() > 1)
|
|
180
|
+
proofs = proofs.slice(0, 1);
|
|
181
|
+
if (total() > avail && lines(head, headFs) > 1)
|
|
182
|
+
headFs = Math.round(hh * 0.11);
|
|
183
|
+
if (total() > avail && lines(bodyText, bodyFs) > 1)
|
|
184
|
+
bodyText = cutWords(body, cjk ? 30 : 55);
|
|
185
|
+
if (total() > avail)
|
|
186
|
+
proofs = [];
|
|
187
|
+
}
|
|
188
|
+
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;
|
|
147
189
|
const children = [];
|
|
148
190
|
if (bg)
|
|
149
191
|
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 }));
|
|
@@ -165,7 +207,8 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
165
207
|
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', {}, '')]),
|
|
166
208
|
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 }, [
|
|
167
209
|
wordsEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }),
|
|
168
|
-
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(
|
|
210
|
+
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }),
|
|
211
|
+
...(chips ? [chips] : []),
|
|
169
212
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
170
213
|
]),
|
|
171
214
|
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)]),
|
|
@@ -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
|
+
}
|
|
@@ -70,7 +70,9 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
70
70
|
- ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
|
|
71
71
|
- text_hook 포맷의 clips[].prompt 는 「자막 뒤에 깔릴 배경 장면」이다 — 타겟이 일하는/사용하는 실사 장면(사람 뒷모습·손·사무실·제품 사용) 또는 추상 도형. 화면 안에 글자·숫자·차트 라벨·UI 를 그리지 말 것(우리 자막이 글자를 담당 · AI 글자는 깨진다).
|
|
72
72
|
- 사람이 나오는 board 는 cast 에 인물 외형 고정 문장(영어 1문장 · 성별/나이대/인종·피부톤/머리/옷/소품 · 브리프 타겟에 맞게)을 쓰고, 그 인물이 이어지는 컷은 clips[].continues=true(앞 컷의 마지막 프레임에서 이어짐 · 인물·장소 동일). 인물 없는 컷은 continues=false.
|
|
73
|
-
- 제품은 물리적으로 말이 되게 — 실제 제품 종류 하나만(쿠션에서 마스카라 브러시가 나오는 식 금지) · 한 컷에 한 제품 · 손과 제품 비율
|
|
73
|
+
- 제품은 물리적으로 말이 되게 — 실제 제품 종류 하나만(쿠션에서 마스카라 브러시가 나오는 식 금지) · 한 컷에 한 제품 · 손과 제품 비율 자연스럽게 · **컷 도중에 없던 부품(어플리케이터·브러시·뚜껑)이 생기거나 제품이 열리는 연출 금지** — 열린 상태를 보이려면 첫 프레임부터 열려 있게.
|
|
74
|
+
- 말하지 않는 장면 포맷(cinematic·lifestyle·text_hook)의 사람은 **카메라를 보며 말하는 듯 서 있지 않는다** — 내레이션(voice)이 따로 얹히므로 인물은 행동 중(제품 사용·이동·친구와 반응)이고 렌즈를 보지 않는다(입 다문 채 카메라 응시 = 「소리는 나는데 입이 안 움직이는」 영상).
|
|
75
|
+
- speech·voice 는 **입으로 읽히는 그대로** 쓴다: 강조용 대문자(ME·BE·SO)·약어(MC·IRL·OP)·기호(=·&·+·%)·이모지 금지 — 발화 모델이 글자 단위로 읽는다. 숫자는 그대로 써도 된다(읽기 형태는 우리가 바꾼다).
|
|
74
76
|
- 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
|
|
75
77
|
- 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,cast,clips:[{sec,kind,prompt,speech,continues}]}]}`,
|
|
76
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 }),
|
|
@@ -83,7 +85,10 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
83
85
|
continue;
|
|
84
86
|
}
|
|
85
87
|
const clips = p.clips.map((pc, i) => { const src = b.clips[i] || b.clips[b.clips.length - 1]; return { sec: pc.sec, kind: pc.kind, prompt: (src?.prompt || '').trim(), speech: src?.speech || undefined, continues: i > 0 ? !!src?.continues : false }; });
|
|
86
|
-
|
|
88
|
+
const caps = b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3);
|
|
89
|
+
// 🔴 장면 포맷의 voice 를 모델이 비우면 내레이션 없는 「음악만」 영상이 된다(힐스테이트 글자 훅 · 2026-09-17) → 자막 두 줄로 내레이션을 만든다
|
|
90
|
+
const voice = b.voice?.trim() || (p.format !== 'ugc_selfie' ? [caps[0], caps[1]].filter(Boolean).join(', ').replace(/[.!?。!?]?$/, '.') : undefined);
|
|
91
|
+
boards.push({ id: `${concept.key}_${p.format}`, format: p.format, genre: p.format === 'ugc_selfie' ? 'ugc' : (b.genre && genres.includes(b.genre) ? b.genre : genres[0]) || 'photoreal', hook: fitHook(b.hook), captions: caps, voice, music: b.music || tone.music, cta, durationSec: o.durationSec, clips, lang, cast: b.cast || undefined });
|
|
87
92
|
}
|
|
88
93
|
return boards;
|
|
89
94
|
}
|
|
@@ -11,5 +11,6 @@ export declare function synthesizeSpeech(o: {
|
|
|
11
11
|
file: string;
|
|
12
12
|
tone?: 'calm' | 'lively' | 'bold';
|
|
13
13
|
persona?: string; /** 이 초 안에 끝나야 하면 넘치는 만큼 배속(최대 1.4배 · 음높이 유지) */
|
|
14
|
-
fitSec?: number;
|
|
14
|
+
fitSec?: number; /** 대문자를 유지할 낱말(브랜드명) */
|
|
15
|
+
keep?: string[];
|
|
15
16
|
}): Promise<TtsResult>;
|
|
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
const execFileP = promisify(execFile);
|
|
6
6
|
import { imageConfig } from './images.js';
|
|
7
7
|
import { probe } from './videofx.js';
|
|
8
|
+
import { spokenForm } from './speech.js';
|
|
8
9
|
const VOICE = { ko: 'nova', ja: 'shimmer', en: 'nova', zh: 'nova', default: 'alloy' };
|
|
9
10
|
export function ttsAvailable() { const c = imageConfig(); return !!c && c.provider === 'bizrouter'; }
|
|
10
11
|
/** 한 문장 내레이션 mp3 — 톤은 광고 UGC(캐주얼·자연스럽게). 실패는 예외. 원가는 응답에 없어 추정(≈₩15/문장). */
|
|
@@ -16,7 +17,7 @@ export async function synthesizeSpeech(o) {
|
|
|
16
17
|
// 2026-09-17 사장님 「말이 너무 느리다」 → 기본 빠른 템포(신난 친구가 소식 전하듯) · calm 도 처지지 않게
|
|
17
18
|
const style = o.tone === 'calm' ? 'warm, trustworthy, brisk but unhurried' : o.tone === 'bold' ? 'energetic, confident, punchy, fast' : 'casual, upbeat, excited, fast';
|
|
18
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}.` : ''}`;
|
|
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) });
|
|
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) });
|
|
20
21
|
if (!res.ok)
|
|
21
22
|
throw new Error(`TTS ${res.status} ${(await res.text()).slice(0, 160)}`);
|
|
22
23
|
fs.writeFileSync(o.file, Buffer.from(await res.arrayBuffer()));
|
|
@@ -74,7 +74,8 @@ export declare function gainToDb(file: string, targetDb: number, min?: number, m
|
|
|
74
74
|
/** 내레이션 얹기 — 원본 소리(음악)는 duck 배로 낮춘 뒤 **말하는 동안만 사이드체인으로 더 눌러**(≈-20dB) 목소리가 또렷하고, 말이 끝나면 음악이 돌아온다(2026-09-17 · 전엔 말소리 감지 시 원본을 0 으로 버려 내레이션 뒤 2초가 무음이었음) · 원본 평균 -20dB 고정 게인(Veo 원본은 -29dB 로 작음) · 원본이 무음이면 TTS 만 */
|
|
75
75
|
export declare function mixVoiceover(video: string, voice: string, output: string, o?: {
|
|
76
76
|
duck?: number;
|
|
77
|
-
startSec?: number;
|
|
77
|
+
startSec?: number; /** 원본 소리를 완전히 버리고 목소리만(원본에 다른 언어 대사가 있을 때 · 겹쳐 들리면 안 됨 · 2026-09-17 ISEKAI'D 일본어 이중 더빙) */
|
|
78
|
+
replace?: boolean;
|
|
78
79
|
}): Promise<void>;
|
|
79
80
|
/** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
|
|
80
81
|
export declare function refCanvas(ref: string, bgPng: string, output: string, dims: {
|
|
@@ -204,10 +204,11 @@ export async function mixVoiceover(video, voice, output, o = {}) {
|
|
|
204
204
|
const p = await probe(video);
|
|
205
205
|
const duck = o.duck ?? 0.18;
|
|
206
206
|
const st = Math.round((o.startSec ?? 0.4) * 1000);
|
|
207
|
+
const useBg = p.hasAudio && !o.replace;
|
|
207
208
|
// 원본(음악)은 평균 -20dB 로 먼저 맞춘 뒤(Veo 원본은 -29dB 로 작음) duck 배 · 목소리는 평균 -18dB
|
|
208
|
-
const bgGain =
|
|
209
|
+
const bgGain = useBg ? await gainToDb(video, -20) : 0;
|
|
209
210
|
const voGain = await gainToDb(voice, -18, -8, 12);
|
|
210
|
-
const fc =
|
|
211
|
+
const fc = useBg
|
|
211
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]`
|
|
212
213
|
: `[1:a]adelay=${st}|${st},volume=${voGain.toFixed(1)}dB,apad,atrim=0:${p.durationSec.toFixed(2)}[a]`;
|
|
213
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 });
|
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