adyou 0.5.2 → 0.6.1
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.d.ts +2 -0
- package/dist/core/creatives/factory.js +89 -8
- package/dist/core/creatives/media.d.ts +5 -1
- package/dist/core/creatives/media.js +13 -4
- package/dist/core/creatives/models.d.ts +5 -0
- package/dist/core/creatives/models.js +35 -0
- package/dist/core/creatives/qa.d.ts +22 -0
- package/dist/core/creatives/qa.js +148 -0
- package/dist/core/creatives/render.js +8 -4
- package/dist/core/creatives/storyboard.js +20 -3
- package/dist/core/creatives/tts.d.ts +14 -0
- package/dist/core/creatives/tts.js +21 -0
- package/dist/core/creatives/video.js +2 -1
- package/dist/core/creatives/videofx.d.ts +10 -0
- package/dist/core/creatives/videofx.js +14 -0
- package/dist/core/ops.js +4 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Brief, Concept, CreativeAsset, Goal } from '../state.js';
|
|
2
2
|
import { type VideoJob, type VideoRatio } from './media.js';
|
|
3
3
|
import { type Tone } from './playbook.js';
|
|
4
|
+
/** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
|
|
5
|
+
export declare function speechModeFor(lang: string): 'native' | 'tts';
|
|
4
6
|
export type VideoAsset = CreativeAsset & {
|
|
5
7
|
type: 'video';
|
|
6
8
|
costKrw: number;
|
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
// CLI(ops.ts)와 웹(pipeline.ts)이 같은 함수를 부른다. 원가는 onAsset 으로 건별 통보(청구는 호출자 · ×1.1).
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { DERIVED_RATIOS, VIDEO_DIMS } from './media.js';
|
|
7
|
+
import { DERIVED_RATIOS, VIDEO_DIMS, hasSeedance, speechVideoModel } from './media.js';
|
|
8
8
|
import { genreStyle, TONE_WORDS } from './playbook.js';
|
|
9
9
|
import { ctaLabel, renderConcept, renderOverlay, renderPhoneStage } from './render.js';
|
|
10
10
|
import { langName, localizedBoard, localizedConcept, planStoryboards, translateCopy } from './storyboard.js';
|
|
11
11
|
import { generateVideo } from './video.js';
|
|
12
|
-
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, pickHookBand, probe, tailLoudnessDb, uiDemoClip } from './videofx.js';
|
|
12
|
+
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, mixVoiceover, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
|
|
13
|
+
import { synthesizeSpeech, ttsAvailable } from './tts.js';
|
|
14
|
+
import { isMockGen } from './images.js';
|
|
15
|
+
import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
|
|
16
|
+
/** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
|
|
17
|
+
export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || hasSeedance() || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
|
|
13
18
|
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
|
|
14
19
|
export async function renderEndCard(dir, brief, concept, ratio, lang, backgrounds = {}) {
|
|
15
20
|
try {
|
|
@@ -106,20 +111,35 @@ export async function produceConceptVideos(o) {
|
|
|
106
111
|
else {
|
|
107
112
|
const person = format === 'ugc_selfie';
|
|
108
113
|
const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
|
|
114
|
+
const mode = line ? speechModeFor(rl) : 'none';
|
|
109
115
|
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
110
|
-
const speech = line ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}
|
|
111
|
-
const prompt = `${clip.prompt}${speech} Style: ${genreStyle(board.genre)}. Camera: ${tw.camera}. Audio: ${job.audio ? `${board.music || tw.music}${person ? ', clear voice over the music' : ''}` : 'silent'}. No on-screen text, no subtitles, no captions, no logos, no watermark.`;
|
|
112
|
-
|
|
116
|
+
const speech = line && mode === 'native' ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}): "${line}". Natural lip sync, conversational, genuine, native-speaker accent. The line is short and finishes comfortably by second ${Math.max(3, clip.sec - 2)}; for the final 1.5 seconds the person is silent, smiling or nodding at the camera (no speech cut-off).` : line ? ` The person does NOT speak at all — lips stay closed or in a natural smile; they react to the camera (nod, raised eyebrows, showing the product or their phone screen, a small laugh) like a silent vlog moment; a narrator voice-over will be added later. Audio: light background music only, no speech, no dialogue, no humming.` : '';
|
|
117
|
+
const prompt = `${clip.prompt}${speech} Style: ${genreStyle(board.genre)}. Camera: ${tw.camera}. Audio: ${job.audio ? `${board.music || tw.music}${person && mode === 'native' ? ', clear voice over the music' : ''}` : 'silent'}. No on-screen text, no subtitles, no captions, no logos, no watermark.`;
|
|
118
|
+
// 첫 프레임: 참고(제품) 사진이 있으면 그 사진을 캔버스에 앉힌 프레임(실제 제품이 움직임) · 없으면 생성 배경 · 사람 포맷은 없음
|
|
119
|
+
let firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
|
|
120
|
+
const refImg = !person && o.refs?.[0];
|
|
121
|
+
if (refImg && ci === 0 && ff) {
|
|
122
|
+
try {
|
|
123
|
+
const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
|
|
124
|
+
firstFrame = await refCanvas(refImg, stage.bg, path.join(o.dir, `${key}_${rl}_refframe.png`), dims);
|
|
125
|
+
log(' 첫 프레임 = 올린 제품 사진');
|
|
126
|
+
}
|
|
127
|
+
catch { /* 생성 배경 유지 */ }
|
|
128
|
+
}
|
|
129
|
+
// 말하는 클립의 모델 — 영어 아닌 언어는 Seedance 네이티브 발화(립싱크) · 영어는 Veo
|
|
130
|
+
const clipModel = line && mode === 'native' ? speechVideoModel(rl, job.model) : job.model;
|
|
131
|
+
if (clipModel !== job.model)
|
|
132
|
+
log(` ${langName(rl)} 대사는 Seedance 2.5 네이티브 발화로(립싱크)`);
|
|
113
133
|
let r;
|
|
114
134
|
try {
|
|
115
|
-
r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model:
|
|
135
|
+
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 });
|
|
116
136
|
}
|
|
117
137
|
catch (e) {
|
|
118
138
|
if (!e.retryable)
|
|
119
139
|
throw e;
|
|
120
140
|
costKrw += Number(e.costKrw || 0);
|
|
121
141
|
log(` 안전 필터로 결과가 비어 와서 다른 테이크로 한 번 더 만들어요`);
|
|
122
|
-
r = await generateVideo({ file: cf, prompt: `${prompt} Alternative take: different framing and wardrobe, softer lighting, keep everything family-friendly and brand-safe.`, ratio: job.ratio, durationSec: clip.sec, model:
|
|
142
|
+
r = await generateVideo({ file: cf, prompt: `${prompt} Alternative take: different framing and wardrobe, softer lighting, keep everything family-friendly and brand-safe.`, ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
|
|
123
143
|
}
|
|
124
144
|
costKrw += r.costKrw;
|
|
125
145
|
// 대사 꼬리 검사 — 마지막 0.7초가 아직 시끄러우면(≥ -27dB) 말이 끝까지 이어진 것 → 더 짧은 대사로 1회 재생성(원가 1회 추가)
|
|
@@ -129,7 +149,7 @@ export async function produceConceptVideos(o) {
|
|
|
129
149
|
log(` 대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB) → 더 짧게 한 번 더 만들어요`);
|
|
130
150
|
const short = line.split(/(?<=[.!?。!?])\s+/)[0] || line;
|
|
131
151
|
try {
|
|
132
|
-
const r2 = await generateVideo({ file: cf, prompt: prompt.replace(`"${line}"`, `"${short}"`).replace('finishes comfortably by second', 'finishes clearly by second'), ratio: job.ratio, durationSec: clip.sec, model:
|
|
152
|
+
const r2 = await generateVideo({ file: cf, prompt: prompt.replace(`"${line}"`, `"${short}"`).replace('finishes comfortably by second', 'finishes clearly by second'), ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
|
|
133
153
|
costKrw += r2.costKrw;
|
|
134
154
|
const t2 = await tailLoudnessDb(cf, 0.7);
|
|
135
155
|
log(` 다시 만든 컷 꼬리 ${t2.toFixed(0)}dB${t2 >= -27 ? ' (여전히 이어짐 · 그대로 사용)' : ' ✓'}`);
|
|
@@ -140,6 +160,40 @@ export async function produceConceptVideos(o) {
|
|
|
140
160
|
}
|
|
141
161
|
}
|
|
142
162
|
log(` 컷 ${ci + 1}/${board.clips.length} ${clip.sec}초 ✓ 원가 ₩${Math.round(r.costKrw).toLocaleString()}`);
|
|
163
|
+
// QA ① 네이티브 대사: 언어·대본·끊김 검사 → 실패면 원본 소리를 낮추고 TTS 내레이션으로 대체
|
|
164
|
+
if (line && mode === 'native' && ff && !isMockGen()) {
|
|
165
|
+
const q = await checkSpeech(cf, { lang: rl, expected: line });
|
|
166
|
+
log(` ${q.note}`);
|
|
167
|
+
if (!q.ok && ttsAvailable()) {
|
|
168
|
+
try {
|
|
169
|
+
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 });
|
|
170
|
+
const mixed = cf.replace(/\.mp4$/, '.vo.mp4');
|
|
171
|
+
await mixVoiceover(cf, v.file, mixed, { duck: 0.08 });
|
|
172
|
+
fs.copyFileSync(mixed, cf);
|
|
173
|
+
costKrw += v.costKrw;
|
|
174
|
+
log(' → TTS 내레이션으로 대체 ✓ (입모양은 안 맞을 수 있어요 · 자막 중심)');
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
log(` ⚠ TTS 대체 실패: ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// QA ② 참고 제품 사진이 있으면 프레임의 제품이 같은지 → 다르면 사진 켄번즈(생성 위험 0)로 교체
|
|
182
|
+
if (refImg && ff && !isMockGen()) {
|
|
183
|
+
const q = await checkProductMatch(cf, refImg);
|
|
184
|
+
log(` ${q.note}`);
|
|
185
|
+
if (!q.ok) {
|
|
186
|
+
try {
|
|
187
|
+
const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
|
|
188
|
+
const canvas = await refCanvas(refImg, stage.bg, path.join(o.dir, `${key}_${rl}_refframe.png`), dims);
|
|
189
|
+
await kenBurnsClip(canvas, cf, { sec: clip.sec, dims });
|
|
190
|
+
log(' → 제품 사진 켄번즈로 교체 ✓');
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
log(` ⚠ 교체 실패(생성 컷 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
143
197
|
}
|
|
144
198
|
clipFiles.push(cf);
|
|
145
199
|
}
|
|
@@ -169,6 +223,33 @@ export async function produceConceptVideos(o) {
|
|
|
169
223
|
log(` ⚠ 이어붙이기 실패(첫 컷만 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
|
|
170
224
|
fs.copyFileSync(clipFiles[0], rawFile);
|
|
171
225
|
}
|
|
226
|
+
// TTS 내레이션(영어 외 시장의 말하는 포맷) — 번역된 대사를 그 언어 음성으로 얹는다
|
|
227
|
+
const ttsLine = board.clips.find((cl) => cl.kind === 'gen' && cl.speech) ? (rl === board.lang ? board.clips.find((cl) => cl.speech).speech : lb0.speech || lb0.voice) : undefined;
|
|
228
|
+
if (ttsLine && speechModeFor(rl) === 'tts' && ff) {
|
|
229
|
+
try {
|
|
230
|
+
// 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
|
|
231
|
+
const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
|
|
232
|
+
const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
|
|
233
|
+
const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
|
|
234
|
+
const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
|
|
235
|
+
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : 0.3 });
|
|
236
|
+
fs.copyFileSync(mixed, rawFile);
|
|
237
|
+
costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
|
|
238
|
+
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 제거)' : ' (원본 음악 유지)'}`);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
log(` ⚠ 내레이션 실패(음악만): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// QA ③ 프레임 위생
|
|
245
|
+
if (ff) {
|
|
246
|
+
const q = await checkFrames(rawFile);
|
|
247
|
+
if (!q.ok) {
|
|
248
|
+
log(` ❌ ${q.note} — 이 포맷은 건너뜀`);
|
|
249
|
+
fs.rmSync(rawFile, { force: true });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
172
253
|
}
|
|
173
254
|
// 3) 언어 × 비율 마감 — 원본(raw)에서 비율을 먼저 만들고 그 위에 자막·엔드카드
|
|
174
255
|
for (const lang of langs) {
|
|
@@ -103,12 +103,16 @@ export declare const SOURCE_LABEL: Record<CreativeSource, string>;
|
|
|
103
103
|
* - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
|
|
104
104
|
* - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
|
|
105
105
|
*/
|
|
106
|
+
/** 말하는 포맷(셀카 후기)의 음성 모델 — 영어는 Veo 네이티브 · 그 외는 Seedance 2.5 네이티브(한국어·일본어 립싱크 검증 · sosigi) · ARK 키 없으면 Veo(+TTS 내레이션 · 인물은 말 안 함) */
|
|
107
|
+
export declare const hasSeedance: () => boolean;
|
|
108
|
+
export declare function speechVideoModel(lang: string, fallback: string): string;
|
|
106
109
|
export declare function resolveCreativePlan(spec: CreativeSpec | undefined, ctx: {
|
|
107
110
|
monthlyKrw: number;
|
|
108
111
|
media: Medium[];
|
|
109
112
|
industry?: string | null;
|
|
110
113
|
genres?: string[];
|
|
111
|
-
hasScreens?: boolean;
|
|
114
|
+
hasScreens?: boolean; /** 시장 언어(말하는 포맷 모델·원가 배정) */
|
|
115
|
+
langs?: string[];
|
|
112
116
|
}): CreativePlan;
|
|
113
117
|
/** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
|
|
114
118
|
export declare const DERIVED_RATIOS: Record<VideoRatio, VideoRatio[]>;
|
|
@@ -28,7 +28,7 @@ export function pickVideoModel(durationSec, tier, provider = 'auto', ratio = '9x
|
|
|
28
28
|
return 'dreamina-seedance-2-5-260628';
|
|
29
29
|
}
|
|
30
30
|
const FORMAT_LABEL_SHORT = { ugc_selfie: '셀카 후기', ui_demo: '화면 데모', cinematic: '시네마틱', product_hero: '제품', lifestyle: '라이프스타일', text_hook: '글자 훅' };
|
|
31
|
-
export const MODE_LABEL = {
|
|
31
|
+
export const MODE_LABEL = { image: '이미지 + 문구(기본)', auto: '이미지 + 영상(베타)', video: '영상 위주(베타)', text: '글·링크 위주' };
|
|
32
32
|
export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구사항·참고 이미지로', manual: '직접 올린 소재로' };
|
|
33
33
|
/**
|
|
34
34
|
* 설정 + 예산 → 실행 계획. 온라인 광고 소재의 일반 배합:
|
|
@@ -37,9 +37,14 @@ export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구
|
|
|
37
37
|
* - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
|
|
38
38
|
* - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
|
|
39
39
|
*/
|
|
40
|
+
/** 말하는 포맷(셀카 후기)의 음성 모델 — 영어는 Veo 네이티브 · 그 외는 Seedance 2.5 네이티브(한국어·일본어 립싱크 검증 · sosigi) · ARK 키 없으면 Veo(+TTS 내레이션 · 인물은 말 안 함) */
|
|
41
|
+
export const hasSeedance = () => !!process.env.ARK_API_KEY;
|
|
42
|
+
export function speechVideoModel(lang, fallback) { return lang.split('-')[0] !== 'en' && hasSeedance() ? 'dreamina-seedance-2-5-260628' : fallback; }
|
|
43
|
+
const SPEAKING_FORMATS = ['ugc_selfie'];
|
|
40
44
|
export function resolveCreativePlan(spec, ctx) {
|
|
41
45
|
const s = spec || {};
|
|
42
|
-
|
|
46
|
+
// 기본 = 이미지 + 문구(안전) · 영상은 베타(광고주 선택) — 2026-09-16 사장님 결정
|
|
47
|
+
const mode = s.mode || 'image';
|
|
43
48
|
const source = s.source || 'ai';
|
|
44
49
|
const small = ctx.monthlyKrw < 500_000;
|
|
45
50
|
const concepts = Math.max(1, Math.min(5, s.concepts || (source === 'manual' ? 1 : mode === 'text' ? 4 : 3)));
|
|
@@ -49,7 +54,7 @@ export function resolveCreativePlan(spec, ctx) {
|
|
|
49
54
|
const ratios = ['1x1', '4x5', '9x16', '16x9'];
|
|
50
55
|
const vTier = s.video?.tier || (ctx.monthlyKrw >= 2_000_000 ? 'standard' : 'economy');
|
|
51
56
|
const duration = s.video?.durationSec || 8;
|
|
52
|
-
const wantVideo = source !== 'manual' && s.video?.count !== 0 && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0
|
|
57
|
+
const wantVideo = source !== 'manual' && s.video?.count !== 0 && mode !== 'text' && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0 || (s.video?.formats?.length || 0) > 0);
|
|
53
58
|
const vRatios = s.video?.ratios?.length ? s.video.ratios : ['9x16'];
|
|
54
59
|
const pb = playbookFor(ctx.industry);
|
|
55
60
|
const tone = s.tone || pb.tone;
|
|
@@ -66,7 +71,11 @@ export function resolveCreativePlan(spec, ctx) {
|
|
|
66
71
|
const sec = Math.min(duration, m.maxSec);
|
|
67
72
|
// ui_demo 는 8초 중 4초만 생성(나머지는 실제 화면 · 무료)
|
|
68
73
|
const genSec = formats[i] === 'ui_demo' && sec <= 8 && (ctx.hasScreens ?? true) ? Math.round(sec / 2) : sec;
|
|
69
|
-
|
|
74
|
+
// 말하는 포맷: 영어 아닌 시장 언어가 있으면 그 언어 클립은 Seedance 원가로(언어별 클립 · 언어 수만큼)
|
|
75
|
+
const langs = ctx.langs?.length ? ctx.langs : ['ko'];
|
|
76
|
+
const speaking = SPEAKING_FORMATS.includes(formats[i]);
|
|
77
|
+
const perLang = speaking ? langs.map((l) => { const mm = VIDEO_MODELS[speechVideoModel(l, model)]; return Math.round(mm.perSecKrw[resolution] * Math.min(sec, mm.maxSec)); }) : [Math.round(m.perSecKrw[resolution] * genSec)];
|
|
78
|
+
perConcept.push({ ratio, durationSec: sec, provider: m.provider, model, resolution, audio: s.video?.audio ?? true, captions: s.video?.captions ?? true, endCard: s.video?.endCard ?? true, unitKrw: perLang.reduce((a, b) => a + b, 0), format: formats[i] });
|
|
70
79
|
}
|
|
71
80
|
const conceptsWithVideo = count ? (small && mode !== 'video' ? 1 : concepts) : 0;
|
|
72
81
|
const img = IMAGE_MODELS[imgTier];
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function listGatewayModels(): Promise<string[]>;
|
|
2
|
+
/** 표(원가 있음)에 있는 모델 중 게이트웨이가 실제로 서비스하는 최신 — 표에 없으면 표의 기본을 그대로 */
|
|
3
|
+
export declare function newestAvailable(candidates: string[], available: string[]): string;
|
|
4
|
+
/** 게이트웨이 목록에 있으나 표에 없는 「더 새 버전」 — 로그로 알려 표를 갱신하게 */
|
|
5
|
+
export declare function newerThanTable(available: string[]): string[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// 모델 자동 선택 — BizRouter `/v1/models` 를 실행 때 읽어(1시간 캐시) Veo·Gemini 이미지의 최신 버전을 티어별로 고른다. 조회 실패면 media.ts 의 고정표.
|
|
2
|
+
// 규칙: 같은 계열에서 버전 숫자가 가장 큰 것 · lite/fast/표준 접미사는 티어에 대응 · 가격표에 없는 새 모델은 원가를 모르니 「표에 있는 최신」만 쓴다(원가 추정 어긋남 방지 · 새 모델은 표 갱신으로 승격).
|
|
3
|
+
import { imageConfig } from './images.js';
|
|
4
|
+
import { IMAGE_MODELS, VIDEO_MODELS } from './media.js';
|
|
5
|
+
let cache = null;
|
|
6
|
+
export async function listGatewayModels() {
|
|
7
|
+
if (cache && Date.now() - cache.at < 3600_000)
|
|
8
|
+
return cache.ids;
|
|
9
|
+
const cfg = imageConfig();
|
|
10
|
+
if (!cfg || cfg.provider !== 'bizrouter')
|
|
11
|
+
return [];
|
|
12
|
+
try {
|
|
13
|
+
const r = await fetch(`${cfg.base}/v1/models`, { headers: { authorization: `Bearer ${cfg.key}` }, signal: AbortSignal.timeout(15_000) });
|
|
14
|
+
const j = (await r.json());
|
|
15
|
+
cache = { at: Date.now(), ids: (j.data || []).map((m) => m.id) };
|
|
16
|
+
return cache.ids;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return cache?.ids || [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const ver = (id) => { const m = /(\d+(?:\.\d+)?)/.exec(id.split('/').pop() || ''); return m ? Number(m[1]) : 0; };
|
|
23
|
+
/** 표(원가 있음)에 있는 모델 중 게이트웨이가 실제로 서비스하는 최신 — 표에 없으면 표의 기본을 그대로 */
|
|
24
|
+
export function newestAvailable(candidates, available) {
|
|
25
|
+
const live = candidates.filter((c) => available.includes(c));
|
|
26
|
+
return (live.length ? live : candidates).sort((a, b) => ver(b) - ver(a))[0];
|
|
27
|
+
}
|
|
28
|
+
/** 게이트웨이 목록에 있으나 표에 없는 「더 새 버전」 — 로그로 알려 표를 갱신하게 */
|
|
29
|
+
export function newerThanTable(available) {
|
|
30
|
+
const tableVeo = Object.keys(VIDEO_MODELS).filter((m) => /veo/.test(m));
|
|
31
|
+
const tableImg = Object.values(IMAGE_MODELS).map((m) => m.model);
|
|
32
|
+
const maxVeo = Math.max(...tableVeo.map(ver));
|
|
33
|
+
const maxImg = Math.max(...tableImg.map(ver));
|
|
34
|
+
return available.filter((id) => (/google\/veo-/.test(id) && ver(id) > maxVeo) || (/google\/gemini-.*-image$/.test(id) && ver(id) > maxImg));
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type QaResult = {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
skipped?: boolean;
|
|
4
|
+
note: string;
|
|
5
|
+
data?: Record<string, unknown>;
|
|
6
|
+
};
|
|
7
|
+
export declare const speechQaAvailable: () => boolean;
|
|
8
|
+
/** 대본 vs 전사 유사도 0~1 — 공백 언어는 단어 겹침 · CJK 는 글자 바이그램 겹침 */
|
|
9
|
+
export declare function transcriptSimilarity(expected: string, transcript: string): number;
|
|
10
|
+
/** 대사 검사 — 언어 일치 + 대본 유사도(≥0.35) + 끊김 아님 */
|
|
11
|
+
export declare function checkSpeech(video: string, o: {
|
|
12
|
+
lang: string;
|
|
13
|
+
expected: string;
|
|
14
|
+
}): Promise<QaResult>;
|
|
15
|
+
/** 제품 일치 — 참고 사진 vs 프레임 2장(1초·중간) */
|
|
16
|
+
export declare function checkProductMatch(video: string, refImage: string): Promise<QaResult>;
|
|
17
|
+
/** 프레임 위생 — 길이·크기·검은 화면 비율 */
|
|
18
|
+
export declare function checkFrames(video: string, minSec?: number): Promise<QaResult>;
|
|
19
|
+
/** 사진에 실존 인물(얼굴)이 있나 — 사이트에서 자동으로 가져온 이미지는 연예인·모델 사진일 수 있어 참고 이미지로 쓰면 초상권 위험(2026-09-16 실측: 뷰티 사이트 대표 이미지가 아이돌) → 사람 없는 제품 사진만 통과 */
|
|
20
|
+
export declare function containsPerson(image: string): Promise<boolean | null>;
|
|
21
|
+
/** 자동 수집 이미지 중 사람 없는 것만(판정 불가면 제외 — 보수적) */
|
|
22
|
+
export declare function filterProductRefs(files: string[], log?: (s: string) => void): Promise<string[]>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// 생성 소재 자동 QA — 영상 크레딧을 쓴 결과물을 사람 눈 대신 기계가 먼저 본다.
|
|
2
|
+
// ① 대사 검사: 소리를 Gemini 2.5 Flash 에 직접 보내(BizRouter 경유는 오디오 입력 불가 · GEMINI_API_KEY) 「어느 언어 · 전사 · 끊김」 → 언어 불일치·대본과 동떨어짐이면 실패
|
|
3
|
+
// ② 제품 일치: 참고(제품) 사진과 영상 프레임을 qwen 비전(BizRouter · 저가)에 나란히 보여 「같은 제품인가」 → 아니면 실패
|
|
4
|
+
// ③ 프레임 위생: 길이 0 · 검은 화면 · 크기 이상
|
|
5
|
+
// 실패하면 호출자가 안전한 대체(TTS 내레이션·무음+자막·제품 사진 켄번즈)로 바꾼다. 키가 없으면 검사를 건너뛰고 skipped 로 표시(막지 않는다).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { imageConfig } from './images.js';
|
|
12
|
+
import { sniffMime } from './render.js';
|
|
13
|
+
import { probe } from './videofx.js';
|
|
14
|
+
const execFileP = promisify(execFile);
|
|
15
|
+
const geminiKey = () => process.env.GEMINI_API_KEY || process.env.ADPILOT_QA_GEMINI_KEY;
|
|
16
|
+
export const speechQaAvailable = () => !!geminiKey();
|
|
17
|
+
const tokens = (t) => t.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
|
|
18
|
+
const bigrams = (t) => { const s = t.replace(/[^\p{L}\p{N}]/gu, ''); const out = new Set(); for (let i = 0; i < s.length - 1; i++)
|
|
19
|
+
out.add(s.slice(i, i + 2)); return out; };
|
|
20
|
+
/** 대본 vs 전사 유사도 0~1 — 공백 언어는 단어 겹침 · CJK 는 글자 바이그램 겹침 */
|
|
21
|
+
export function transcriptSimilarity(expected, transcript) {
|
|
22
|
+
const cjk = /[-ヿ㐀-鿿가-]/.test(expected);
|
|
23
|
+
if (cjk) {
|
|
24
|
+
const a = bigrams(expected), b = bigrams(transcript);
|
|
25
|
+
if (!a.size)
|
|
26
|
+
return 0;
|
|
27
|
+
let hit = 0;
|
|
28
|
+
for (const x of a)
|
|
29
|
+
if (b.has(x))
|
|
30
|
+
hit++;
|
|
31
|
+
return hit / a.size;
|
|
32
|
+
}
|
|
33
|
+
const a = tokens(expected), b = new Set(tokens(transcript));
|
|
34
|
+
if (!a.length)
|
|
35
|
+
return 0;
|
|
36
|
+
return a.filter((w) => b.has(w)).length / a.length;
|
|
37
|
+
}
|
|
38
|
+
/** 대사 검사 — 언어 일치 + 대본 유사도(≥0.35) + 끊김 아님 */
|
|
39
|
+
export async function checkSpeech(video, o) {
|
|
40
|
+
const key = geminiKey();
|
|
41
|
+
if (!key)
|
|
42
|
+
return { ok: true, skipped: true, note: '대사 검사 건너뜀(검사 키 없음)' };
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-qa-'));
|
|
44
|
+
const mp3 = path.join(dir, 'a.mp3');
|
|
45
|
+
try {
|
|
46
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', video, '-vn', '-ac', '1', '-ar', '16000', '-b:a', '48k', mp3]);
|
|
47
|
+
const b64 = fs.readFileSync(mp3).toString('base64');
|
|
48
|
+
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: 'This is the audio of a short social video ad. Return JSON only: {"lang": ISO 639-1 code of the spoken language or "none" if no speech, "transcript": verbatim speech, "cutoff": true if the speech is cut off mid-sentence at the end, "garbled": true if the speech is unintelligible or nonsensical for a native listener}.' }, { inline_data: { mime_type: 'audio/mp3', data: b64 } }] }], generationConfig: { responseMimeType: 'application/json' } }), signal: AbortSignal.timeout(90_000) });
|
|
49
|
+
const j = (await r.json());
|
|
50
|
+
const txt = j.candidates?.[0]?.content?.parts?.[0]?.text || '{}';
|
|
51
|
+
const d = JSON.parse(txt.replace(/```json|```/g, ''));
|
|
52
|
+
const lang = String(d.lang || 'none').slice(0, 2).toLowerCase();
|
|
53
|
+
const want = o.lang.split('-')[0].toLowerCase();
|
|
54
|
+
const sim = transcriptSimilarity(o.expected, d.transcript || '');
|
|
55
|
+
const cutoff = d.cutoff === true || d.cutoff === 'yes' || d.cutoff === 'true';
|
|
56
|
+
const garbled = d.garbled === true || d.garbled === 'yes' || d.garbled === 'true';
|
|
57
|
+
const problems = [];
|
|
58
|
+
if (lang === 'none')
|
|
59
|
+
problems.push('말소리 없음');
|
|
60
|
+
else if (lang !== want)
|
|
61
|
+
problems.push(`언어 불일치(${lang}≠${want})`);
|
|
62
|
+
if (sim < 0.35)
|
|
63
|
+
problems.push(`대본과 다름(${Math.round(sim * 100)}%)`);
|
|
64
|
+
if (garbled)
|
|
65
|
+
problems.push('발음 불명확');
|
|
66
|
+
if (cutoff)
|
|
67
|
+
problems.push('끝에서 끊김');
|
|
68
|
+
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
|
+
catch (e) {
|
|
71
|
+
return { ok: true, skipped: true, note: `대사 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 80) : e})` };
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const dataUrl = (file) => { const b = fs.readFileSync(file); return `data:${sniffMime(b)};base64,${b.toString('base64')}`; };
|
|
78
|
+
/** 제품 일치 — 참고 사진 vs 프레임 2장(1초·중간) */
|
|
79
|
+
export async function checkProductMatch(video, refImage) {
|
|
80
|
+
const cfg = imageConfig();
|
|
81
|
+
if (!cfg || cfg.provider !== 'bizrouter')
|
|
82
|
+
return { ok: true, skipped: true, note: '제품 검사 건너뜀(키 없음)' };
|
|
83
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-qa-'));
|
|
84
|
+
try {
|
|
85
|
+
const p = await probe(video);
|
|
86
|
+
const frames = [];
|
|
87
|
+
for (const t of [1, Math.max(1.5, p.durationSec / 2)]) {
|
|
88
|
+
const f = path.join(dir, `f${frames.length}.jpg`);
|
|
89
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(2), '-i', video, '-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '4', f]);
|
|
90
|
+
frames.push(f);
|
|
91
|
+
}
|
|
92
|
+
const res = await fetch(`${cfg.base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'bizrouter/qwen-3.8-flash', max_tokens: 200, messages: [{ role: 'user', content: [{ type: 'text', text: 'Image 1 is the advertiser\'s real product photo (reference). Images 2-3 are frames from a generated ad video. Is the product shown in the frames the SAME product as the reference — same overall shape, packaging type, and dominant colors (label text may differ slightly)? Answer JSON only: {"same": true|false, "confidence": 0-1, "reason": "<10 words"}' }, { type: 'image_url', image_url: { url: dataUrl(refImage) } }, ...frames.map((f) => ({ type: 'image_url', image_url: { url: dataUrl(f) } }))] }] }), signal: AbortSignal.timeout(90_000) });
|
|
93
|
+
const j = (await res.json());
|
|
94
|
+
const txt = j.choices?.[0]?.message?.content || '{}';
|
|
95
|
+
const m = /\{[\s\S]*\}/.exec(txt);
|
|
96
|
+
const d = JSON.parse(m ? m[0] : '{}');
|
|
97
|
+
const ok = d.same === true && (d.confidence ?? 1) >= 0.5;
|
|
98
|
+
return { ok, note: ok ? `제품 일치 ✓ (${Math.round((d.confidence ?? 1) * 100)}%)` : `제품 불일치: ${d.reason || '참고 사진과 다른 제품'}`, data: d };
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return { ok: true, skipped: true, note: `제품 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 80) : e})` };
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** 프레임 위생 — 길이·크기·검은 화면 비율 */
|
|
108
|
+
export async function checkFrames(video, minSec = 2) {
|
|
109
|
+
try {
|
|
110
|
+
const p = await probe(video);
|
|
111
|
+
if (p.durationSec < minSec || !p.w || !p.h)
|
|
112
|
+
return { ok: false, note: `영상 이상(길이 ${p.durationSec.toFixed(1)}초 · ${p.w}×${p.h})` };
|
|
113
|
+
const { stderr } = await execFileP('ffmpeg', ['-v', 'info', '-i', video, '-vf', 'blackdetect=d=0.5:pix_th=0.10', '-an', '-f', 'null', '-'], { maxBuffer: 4 << 20 });
|
|
114
|
+
const black = [...String(stderr).matchAll(/black_duration:([\d.]+)/g)].reduce((n, m) => n + Number(m[1]), 0);
|
|
115
|
+
if (black > p.durationSec * 0.4)
|
|
116
|
+
return { ok: false, note: `검은 화면 ${black.toFixed(1)}초` };
|
|
117
|
+
return { ok: true, note: '프레임 ✓' };
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return { ok: true, skipped: true, note: `프레임 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 60) : e})` };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** 사진에 실존 인물(얼굴)이 있나 — 사이트에서 자동으로 가져온 이미지는 연예인·모델 사진일 수 있어 참고 이미지로 쓰면 초상권 위험(2026-09-16 실측: 뷰티 사이트 대표 이미지가 아이돌) → 사람 없는 제품 사진만 통과 */
|
|
124
|
+
export async function containsPerson(image) {
|
|
125
|
+
const cfg = imageConfig();
|
|
126
|
+
if (!cfg || cfg.provider !== 'bizrouter')
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(`${cfg.base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'bizrouter/qwen-3.8-flash', max_tokens: 20, messages: [{ role: 'user', content: [{ type: 'text', text: 'Does this image contain a real human person or face (photo or realistic render)? Answer exactly "yes" or "no".' }, { type: 'image_url', image_url: { url: dataUrl(image) } }] }] }), signal: AbortSignal.timeout(60_000) });
|
|
130
|
+
const j = (await res.json());
|
|
131
|
+
return /yes/i.test(j.choices?.[0]?.message?.content || '');
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** 자동 수집 이미지 중 사람 없는 것만(판정 불가면 제외 — 보수적) */
|
|
138
|
+
export async function filterProductRefs(files, log) {
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const f of files) {
|
|
141
|
+
const p = await containsPerson(f);
|
|
142
|
+
if (p === false)
|
|
143
|
+
out.push(f);
|
|
144
|
+
else
|
|
145
|
+
log?.(` 사이트 이미지 ${f.split('/').pop()} 은 사람이 있어 참고에서 제외(초상권)`);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
@@ -120,9 +120,13 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
120
120
|
const sub = dark ? 'rgba(255,255,255,.82)' : 'rgba(11,21,38,.72)';
|
|
121
121
|
const bgStyle = bg ? {} : { backgroundImage: dark ? `linear-gradient(135deg, ${brief.palette.dark} 0%, ${hexA(primary, 0.55)} 100%)` : `linear-gradient(135deg, ${brief.palette.light} 0%, ${hexA(primary, 0.18)} 100%)` };
|
|
122
122
|
// 크기별 글자 크기
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
123
|
+
// 글자 크기 — 메타 포스터(피드·스토리)는 폰 화면에서 읽히게 크게(짧은 변의 11~12% · 메타는 텍스트 20% 규칙이 없음 · 2021 폐지)
|
|
124
|
+
// 구글 디스플레이 이미지는 반대로 글자를 적게(구글이 헤드라인·설명을 따로 얹고, 이미지 텍스트 20% 초과는 비승인 위험) → 헤드라인만 작게
|
|
125
|
+
const googleLight = size.medium === 'google' && !banner;
|
|
126
|
+
// 구글 이미지(1200×628·1200×1200)는 텍스트 20% 안에서 헤드라인 8.5% + 본문 한 줄 3.6% — 너무 비면 썰렁(사장님 검수) · 메타보다 한 단계 작게
|
|
127
|
+
const headFs = short ? Math.round(hh * 0.34) : narrow ? Math.round(w * 0.14) : banner ? Math.round(base * 0.12) : googleLight ? Math.round(base * 0.085) : Math.round(base * (hh > w ? 0.12 : 0.105));
|
|
128
|
+
const bodyFs = short ? 0 : narrow ? Math.round(w * 0.085) : banner ? Math.round(base * 0.062) : googleLight ? Math.round(base * 0.036) : Math.round(base * 0.046);
|
|
129
|
+
const ctaFs = short ? Math.round(hh * 0.28) : narrow ? Math.round(w * 0.09) : banner ? Math.round(base * 0.06) : googleLight ? Math.round(base * 0.036) : Math.round(base * 0.04);
|
|
126
130
|
const children = [];
|
|
127
131
|
if (bg)
|
|
128
132
|
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 }));
|
|
@@ -144,7 +148,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
144
148
|
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', {}, '')]),
|
|
145
149
|
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 }, [
|
|
146
150
|
h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5, wordBreak: 'keep-all' }, head),
|
|
147
|
-
banner && bodyFs < 14 ? h('div', {}, '') : h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45, wordBreak: 'keep-all' }, banner ? body.slice(0, 70) : body),
|
|
151
|
+
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45, wordBreak: 'keep-all' }, banner ? body.slice(0, 70) : googleLight ? body.slice(0, 48) : body),
|
|
148
152
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
149
153
|
]),
|
|
150
154
|
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)]),
|
|
@@ -122,10 +122,27 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
122
122
|
system: `너는 광고 현지화 카피라이터다. 아래 문구·자막·훅·대사를 각 언어로 옮긴다. 직역이 아니라 그 시장 광고에서 자연스러운 표현으로, 뜻·사실·숫자는 그대로. 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자(한글·전각 2자) · 설명 ≤ ${GOOGLE_LIMITS.description}자 · 자막 각 18자(영문 32자) 이내 · 훅 6단어 이내 · 대사(voice·speech)는 말하면 4~5초 안에 끝나는 짧은 한 문장(영어 ≤ 12단어 · 일본어 ≤ 22자 · 그 언어 원어민이 셀카에서 실제로 쓰는 구어체). 브랜드명(${brief.company})은 번역하지 않는다. 출력 JSON {"translations":{"<lang>":{headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice,speech}]}}}`,
|
|
123
123
|
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 })) }),
|
|
124
124
|
});
|
|
125
|
+
// 키 정규화 — 모델이 "ja (Japanese)"·"Japanese"·"JA" 로 돌려주기도 함
|
|
126
|
+
const norm = (k) => k.trim().toLowerCase().split(/[\s(]/)[0];
|
|
127
|
+
const byKey = {};
|
|
128
|
+
for (const [k, v] of Object.entries(r.translations)) {
|
|
129
|
+
byKey[norm(k)] = v;
|
|
130
|
+
const nm = Object.entries(LANG_NAME).find(([, name]) => name.toLowerCase() === k.trim().toLowerCase());
|
|
131
|
+
if (nm)
|
|
132
|
+
byKey[nm[0].toLowerCase()] = v;
|
|
133
|
+
}
|
|
125
134
|
for (const l of targets) {
|
|
126
|
-
|
|
127
|
-
if (!t)
|
|
128
|
-
|
|
135
|
+
let t = byKey[l.toLowerCase()] || byKey[l.split('-')[0].toLowerCase()];
|
|
136
|
+
if (!t) {
|
|
137
|
+
// 한 언어만 다시(누락 보정) · 그래도 없으면 원문으로 채워 소재 생성은 막지 않는다
|
|
138
|
+
try {
|
|
139
|
+
const one = await completeJson({ schema: z.object({ headlines: z.array(z.string()), bodies: z.array(z.string()), descriptions: z.array(z.string()), boards: z.array(z.object({ id: z.string(), hook: z.string(), captions: z.array(z.string()), voice: z.string().optional().nullable(), speech: z.string().optional().nullable() })).optional() }), maxTokens: 3000, timeoutMs: 120_000, system: `광고 문구를 ${langName(l)}(${l}) 로 자연스럽게 옮긴다. 뜻·사실·숫자 유지 · 브랜드명 유지 · 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자 · 자막 각 18자(영문 32자) · 대사는 4~5초 한 문장. 출력 JSON {headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice,speech}]}`, user: JSON.stringify({ headlines: concept.headlines, bodies: concept.bodies, descriptions: concept.descriptions, boards: boards.map((b) => ({ id: b.id, hook: b.hook, captions: b.captions, voice: b.voice, speech: b.clips.find((c) => c.speech)?.speech })) }) });
|
|
140
|
+
t = one;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
t = { headlines: concept.headlines, bodies: concept.bodies, descriptions: concept.descriptions, boards: [] };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
129
146
|
out[l] = { headlines: t.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean), bodies: t.bodies.map((b) => b.trim()).filter(Boolean), descriptions: t.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean) };
|
|
130
147
|
for (const b of boards) {
|
|
131
148
|
const tb = t.boards?.find((x) => x.id === b.id);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type TtsResult = {
|
|
2
|
+
file: string;
|
|
3
|
+
durationSec: number;
|
|
4
|
+
costKrw: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function ttsAvailable(): boolean;
|
|
7
|
+
/** 한 문장 내레이션 mp3 — 톤은 광고 UGC(캐주얼·자연스럽게). 실패는 예외. 원가는 응답에 없어 추정(≈₩15/문장). */
|
|
8
|
+
export declare function synthesizeSpeech(o: {
|
|
9
|
+
text: string;
|
|
10
|
+
lang: string;
|
|
11
|
+
file: string;
|
|
12
|
+
tone?: 'calm' | 'lively' | 'bold';
|
|
13
|
+
persona?: string;
|
|
14
|
+
}): Promise<TtsResult>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// 내레이션 TTS — BizRouter `/v1/audio/speech`(openai/gpt-4o-mini-tts · 한국어 자연도 실측 10/10). 영어가 아닌 시장에서 「말하는 포맷」의 음성은 Veo 발화 대신 TTS 를 얹는다(Veo 한국어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { imageConfig } from './images.js';
|
|
4
|
+
import { probe } from './videofx.js';
|
|
5
|
+
const VOICE = { ko: 'nova', ja: 'shimmer', en: 'nova', zh: 'nova', default: 'alloy' };
|
|
6
|
+
export function ttsAvailable() { const c = imageConfig(); return !!c && c.provider === 'bizrouter'; }
|
|
7
|
+
/** 한 문장 내레이션 mp3 — 톤은 광고 UGC(캐주얼·자연스럽게). 실패는 예외. 원가는 응답에 없어 추정(≈₩15/문장). */
|
|
8
|
+
export async function synthesizeSpeech(o) {
|
|
9
|
+
const cfg = imageConfig();
|
|
10
|
+
if (!cfg || cfg.provider !== 'bizrouter')
|
|
11
|
+
throw Object.assign(new Error('TTS 키(BizRouter)가 없어요'), { fatal: true });
|
|
12
|
+
const lang = o.lang.split('-')[0];
|
|
13
|
+
const style = o.tone === 'calm' ? 'warm, calm, trustworthy' : o.tone === 'bold' ? 'energetic, confident, punchy' : 'casual, upbeat, friendly';
|
|
14
|
+
const instructions = `Speak in natural ${lang === 'ko' ? 'Korean' : lang === 'ja' ? 'Japanese' : lang === 'zh' ? 'Chinese' : 'the given language'} like a real person talking to their phone camera in a short social video ad: ${style}, conversational pace, not read from a script.${o.persona ? ` Speaker: ${o.persona}.` : ''}`;
|
|
15
|
+
const res = await fetch(`${cfg.base}/v1/audio/speech`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'openai/gpt-4o-mini-tts', input: o.text, voice: VOICE[lang] || VOICE.default, instructions, response_format: 'mp3' }), signal: AbortSignal.timeout(60_000) });
|
|
16
|
+
if (!res.ok)
|
|
17
|
+
throw new Error(`TTS ${res.status} ${(await res.text()).slice(0, 160)}`);
|
|
18
|
+
fs.writeFileSync(o.file, Buffer.from(await res.arrayBuffer()));
|
|
19
|
+
const p = await probe(o.file).catch(() => ({ durationSec: 0 }));
|
|
20
|
+
return { file: o.file, durationSec: p.durationSec, costKrw: 15 };
|
|
21
|
+
}
|
|
@@ -34,7 +34,8 @@ async function mockVideo(i) {
|
|
|
34
34
|
let n = 0;
|
|
35
35
|
for (const ch of i.prompt)
|
|
36
36
|
n = (n * 31 + ch.charCodeAt(0)) >>> 0;
|
|
37
|
-
|
|
37
|
+
// 모의 색은 어둡지 않게(QA 검은 화면 검사에 걸리지 않도록) — 각 채널 96~255
|
|
38
|
+
const color = `0x${[16, 8, 0].map((sh) => (96 + ((n >> sh) & 0xff) % 160).toString(16).padStart(2, '0')).join('')}`;
|
|
38
39
|
const inputs = i.firstFrame && fs.existsSync(i.firstFrame)
|
|
39
40
|
? ['-loop', '1', '-t', String(i.durationSec), '-i', i.firstFrame, '-f', 'lavfi', '-t', String(i.durationSec), '-i', 'anullsrc=r=44100:cl=stereo', '-vf', `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},format=yuv420p`]
|
|
40
41
|
: ['-f', 'lavfi', '-t', String(i.durationSec), '-i', `color=c=${color}:s=${w}x${h}:r=24`, '-f', 'lavfi', '-t', String(i.durationSec), '-i', 'anullsrc=r=44100:cl=stereo', '-vf', 'format=yuv420p'];
|
|
@@ -64,3 +64,13 @@ export declare function kenBurnsClip(image: string, output: string, o: {
|
|
|
64
64
|
export declare function pickHookBand(video: string, atSec?: number): Promise<'top' | 'middle' | 'lower'>;
|
|
65
65
|
/** 마지막 tailSec 초의 평균 음량(dBFS) — 대사가 끝까지 이어져 「뚝」 끊기는지 판정(무음·음악만이면 낮음). 실패 시 -99. */
|
|
66
66
|
export declare function tailLoudnessDb(file: string, tailSec?: number): Promise<number>;
|
|
67
|
+
/** 내레이션 얹기 — 원본 소리는 낮추고(duck) 0.4초 뒤부터 TTS · 원본이 무음이면 TTS 만 */
|
|
68
|
+
export declare function mixVoiceover(video: string, voice: string, output: string, o?: {
|
|
69
|
+
duck?: number;
|
|
70
|
+
startSec?: number;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
/** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
|
|
73
|
+
export declare function refCanvas(ref: string, bgPng: string, output: string, dims: {
|
|
74
|
+
w: number;
|
|
75
|
+
h: number;
|
|
76
|
+
}): Promise<string>;
|
|
@@ -176,3 +176,17 @@ export async function tailLoudnessDb(file, tailSec = 0.7) {
|
|
|
176
176
|
return -99;
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
/** 내레이션 얹기 — 원본 소리는 낮추고(duck) 0.4초 뒤부터 TTS · 원본이 무음이면 TTS 만 */
|
|
180
|
+
export async function mixVoiceover(video, voice, output, o = {}) {
|
|
181
|
+
const p = await probe(video);
|
|
182
|
+
const duck = o.duck ?? 0.18;
|
|
183
|
+
const st = Math.round((o.startSec ?? 0.4) * 1000);
|
|
184
|
+
const fc = p.hasAudio ? `[0:a]volume=${duck}[bg];[1:a]adelay=${st}|${st},volume=1.0[vo];[bg][vo]amix=inputs=2:duration=first:dropout_transition=0,alimiter=limit=0.95[a]` : `[1:a]adelay=${st}|${st},apad,atrim=0:${p.durationSec.toFixed(2)}[a]`;
|
|
185
|
+
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
|
+
}
|
|
187
|
+
/** 참고(제품) 사진을 영상 크기 캔버스에 앉힌 첫 프레임 — 배경 PNG(브랜드 그래디언트) 위에 사진을 82% 폭으로 · 실제 제품이 그대로 움직이게 */
|
|
188
|
+
export async function refCanvas(ref, bgPng, output, dims) {
|
|
189
|
+
const w = Math.round(dims.w * 0.82);
|
|
190
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', bgPng, '-i', ref, '-filter_complex', `[1:v]scale=${w}:-1:force_original_aspect_ratio=decrease,scale='min(iw,${w})':'min(ih,${Math.round(dims.h * 0.7)})':force_original_aspect_ratio=decrease[p];[0:v]scale=${dims.w}:${dims.h}[b];[b][p]overlay=(W-w)/2:(H-h)/2,format=rgb24`, '-frames:v', '1', output]);
|
|
191
|
+
return output;
|
|
192
|
+
}
|
package/dist/core/ops.js
CHANGED
|
@@ -8,6 +8,7 @@ import { generateBackgrounds, userBackgrounds } from './creatives/images.js';
|
|
|
8
8
|
import { chargeFor, resolveCreativePlan } from './creatives/media.js';
|
|
9
9
|
import { produceConceptVideos } from './creatives/factory.js';
|
|
10
10
|
import { downloadSiteImages, screenshotSite } from './creatives/siteshots.js';
|
|
11
|
+
import { filterProductRefs } from './creatives/qa.js';
|
|
11
12
|
import { renderConcept, renderLogo } from './creatives/render.js';
|
|
12
13
|
import { SIZES } from './creatives/specs.js';
|
|
13
14
|
import { aiAvailable, completeJson } from './llm.js';
|
|
@@ -61,7 +62,7 @@ export async function opCreatives(p, o) {
|
|
|
61
62
|
spec.video = { ...(spec.video || {}), count: 1 };
|
|
62
63
|
if (o.videoSeconds)
|
|
63
64
|
spec.video = { ...(spec.video || {}), durationSec: (o.videoSeconds <= 8 ? 8 : o.videoSeconds <= 15 ? 15 : 30) };
|
|
64
|
-
const cplan = resolveCreativePlan(spec, { monthlyKrw: p.budget.monthlyKrw, media: connectedMedia(), industry: p.brief.industry, genres: p.brief.visualGenres, hasScreens: true });
|
|
65
|
+
const cplan = resolveCreativePlan(spec, { monthlyKrw: p.budget.monthlyKrw, media: connectedMedia(), industry: p.brief.industry, genres: p.brief.visualGenres, hasScreens: true, langs: [...new Set(p.budget.markets.map((m) => m.language))] });
|
|
65
66
|
p.creative = spec;
|
|
66
67
|
const count = o.count || cplan.concepts;
|
|
67
68
|
const dir = creativesDir(p.slug);
|
|
@@ -91,8 +92,8 @@ export async function opCreatives(p, o) {
|
|
|
91
92
|
let siteImages = [];
|
|
92
93
|
if (cplan.videos.perConcept.length) {
|
|
93
94
|
siteShot = await screenshotSite(p.url, path.join(dir, 'site_shot.png'));
|
|
94
|
-
siteImages = await downloadSiteImages(p.brief.siteImages || [], dir,
|
|
95
|
-
log(`사이트 화면 ${siteShot ? '캡처 ✓' : '캡처 불가(크로미움 없음)'} · 사이트 이미지 ${siteImages.length}장`);
|
|
95
|
+
siteImages = await filterProductRefs(await downloadSiteImages(p.brief.siteImages || [], dir, 4), log);
|
|
96
|
+
log(`사이트 화면 ${siteShot ? '캡처 ✓' : '캡처 불가(크로미움 없음)'} · 사이트 이미지(사람 없는 것) ${siteImages.length}장`);
|
|
96
97
|
}
|
|
97
98
|
const langs = [...new Set(p.budget.markets.map((m) => m.language))];
|
|
98
99
|
p.concepts.forEach((c, i) => { void i; });
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,9 @@ export * from './core/creatives/factory.js';
|
|
|
14
14
|
export * from './core/creatives/playbook.js';
|
|
15
15
|
export * from './core/creatives/storyboard.js';
|
|
16
16
|
export * from './core/creatives/siteshots.js';
|
|
17
|
+
export * from './core/creatives/tts.js';
|
|
18
|
+
export * from './core/creatives/qa.js';
|
|
19
|
+
export * from './core/creatives/models.js';
|
|
17
20
|
export { computePlan, type PlanInput } from './core/plan.js';
|
|
18
21
|
export * from './core/optimize.js';
|
|
19
22
|
export * from './core/report.js';
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,9 @@ export * from './core/creatives/factory.js';
|
|
|
15
15
|
export * from './core/creatives/playbook.js';
|
|
16
16
|
export * from './core/creatives/storyboard.js';
|
|
17
17
|
export * from './core/creatives/siteshots.js';
|
|
18
|
+
export * from './core/creatives/tts.js';
|
|
19
|
+
export * from './core/creatives/qa.js';
|
|
20
|
+
export * from './core/creatives/models.js';
|
|
18
21
|
export { computePlan } from './core/plan.js';
|
|
19
22
|
export * from './core/optimize.js';
|
|
20
23
|
export * from './core/report.js';
|
package/package.json
CHANGED