adyou 0.6.0 → 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.
|
@@ -4,7 +4,7 @@
|
|
|
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';
|
|
@@ -14,7 +14,7 @@ import { synthesizeSpeech, ttsAvailable } from './tts.js';
|
|
|
14
14
|
import { isMockGen } from './images.js';
|
|
15
15
|
import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
|
|
16
16
|
/** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
|
|
17
|
-
export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
|
|
17
|
+
export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || hasSeedance() || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
|
|
18
18
|
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
|
|
19
19
|
export async function renderEndCard(dir, brief, concept, ratio, lang, backgrounds = {}) {
|
|
20
20
|
try {
|
|
@@ -113,7 +113,7 @@ export async function produceConceptVideos(o) {
|
|
|
113
113
|
const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
|
|
114
114
|
const mode = line ? speechModeFor(rl) : 'none';
|
|
115
115
|
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
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
|
|
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
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
118
|
// 첫 프레임: 참고(제품) 사진이 있으면 그 사진을 캔버스에 앉힌 프레임(실제 제품이 움직임) · 없으면 생성 배경 · 사람 포맷은 없음
|
|
119
119
|
let firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
|
|
@@ -126,16 +126,20 @@ export async function produceConceptVideos(o) {
|
|
|
126
126
|
}
|
|
127
127
|
catch { /* 생성 배경 유지 */ }
|
|
128
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 네이티브 발화로(립싱크)`);
|
|
129
133
|
let r;
|
|
130
134
|
try {
|
|
131
|
-
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 });
|
|
132
136
|
}
|
|
133
137
|
catch (e) {
|
|
134
138
|
if (!e.retryable)
|
|
135
139
|
throw e;
|
|
136
140
|
costKrw += Number(e.costKrw || 0);
|
|
137
141
|
log(` 안전 필터로 결과가 비어 와서 다른 테이크로 한 번 더 만들어요`);
|
|
138
|
-
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 });
|
|
139
143
|
}
|
|
140
144
|
costKrw += r.costKrw;
|
|
141
145
|
// 대사 꼬리 검사 — 마지막 0.7초가 아직 시끄러우면(≥ -27dB) 말이 끝까지 이어진 것 → 더 짧은 대사로 1회 재생성(원가 1회 추가)
|
|
@@ -145,7 +149,7 @@ export async function produceConceptVideos(o) {
|
|
|
145
149
|
log(` 대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB) → 더 짧게 한 번 더 만들어요`);
|
|
146
150
|
const short = line.split(/(?<=[.!?。!?])\s+/)[0] || line;
|
|
147
151
|
try {
|
|
148
|
-
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 });
|
|
149
153
|
costKrw += r2.costKrw;
|
|
150
154
|
const t2 = await tailLoudnessDb(cf, 0.7);
|
|
151
155
|
log(` 다시 만든 컷 꼬리 ${t2.toFixed(0)}dB${t2 >= -27 ? ' (여전히 이어짐 · 그대로 사용)' : ' ✓'}`);
|
|
@@ -167,7 +171,7 @@ export async function produceConceptVideos(o) {
|
|
|
167
171
|
await mixVoiceover(cf, v.file, mixed, { duck: 0.08 });
|
|
168
172
|
fs.copyFileSync(mixed, cf);
|
|
169
173
|
costKrw += v.costKrw;
|
|
170
|
-
log(' → TTS 내레이션으로 대체 ✓');
|
|
174
|
+
log(' → TTS 내레이션으로 대체 ✓ (입모양은 안 맞을 수 있어요 · 자막 중심)');
|
|
171
175
|
}
|
|
172
176
|
catch (e) {
|
|
173
177
|
log(` ⚠ TTS 대체 실패: ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
@@ -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[]>;
|
|
@@ -37,6 +37,10 @@ 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 사장님 결정
|
|
@@ -67,7 +71,11 @@ export function resolveCreativePlan(spec, ctx) {
|
|
|
67
71
|
const sec = Math.min(duration, m.maxSec);
|
|
68
72
|
// ui_demo 는 8초 중 4초만 생성(나머지는 실제 화면 · 무료)
|
|
69
73
|
const genSec = formats[i] === 'ui_demo' && sec <= 8 && (ctx.hasScreens ?? true) ? Math.round(sec / 2) : sec;
|
|
70
|
-
|
|
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] });
|
|
71
79
|
}
|
|
72
80
|
const conceptsWithVideo = count ? (small && mode !== 'video' ? 1 : concepts) : 0;
|
|
73
81
|
const img = IMAGE_MODELS[imgTier];
|
|
@@ -123,9 +123,10 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
123
123
|
// 글자 크기 — 메타 포스터(피드·스토리)는 폰 화면에서 읽히게 크게(짧은 변의 11~12% · 메타는 텍스트 20% 규칙이 없음 · 2021 폐지)
|
|
124
124
|
// 구글 디스플레이 이미지는 반대로 글자를 적게(구글이 헤드라인·설명을 따로 얹고, 이미지 텍스트 20% 초과는 비승인 위험) → 헤드라인만 작게
|
|
125
125
|
const googleLight = size.medium === 'google' && !banner;
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
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);
|
|
129
130
|
const children = [];
|
|
130
131
|
if (bg)
|
|
131
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 }));
|
|
@@ -147,7 +148,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
147
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', {}, '')]),
|
|
148
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 }, [
|
|
149
150
|
h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5, wordBreak: 'keep-all' }, head),
|
|
150
|
-
(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) : 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),
|
|
151
152
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
152
153
|
]),
|
|
153
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)]),
|
package/dist/core/ops.js
CHANGED
|
@@ -62,7 +62,7 @@ export async function opCreatives(p, o) {
|
|
|
62
62
|
spec.video = { ...(spec.video || {}), count: 1 };
|
|
63
63
|
if (o.videoSeconds)
|
|
64
64
|
spec.video = { ...(spec.video || {}), durationSec: (o.videoSeconds <= 8 ? 8 : o.videoSeconds <= 15 ? 15 : 30) };
|
|
65
|
-
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))] });
|
|
66
66
|
p.creative = spec;
|
|
67
67
|
const count = o.count || cplan.concepts;
|
|
68
68
|
const dir = creativesDir(p.slug);
|
package/package.json
CHANGED