adyou 0.6.11 → 0.6.13
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.
|
@@ -315,8 +315,8 @@ export async function produceConceptVideos(o) {
|
|
|
315
315
|
const rawSec = (await probe(rawFile).catch(() => ({ durationSec: job.durationSec }))).durationSec;
|
|
316
316
|
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] });
|
|
317
317
|
const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
|
|
318
|
-
//
|
|
319
|
-
const replace = hasSpeech
|
|
318
|
+
// 원본에 말소리가 있으면(다른 언어 대사·Veo 영어 웅얼거림) 어느 포맷이든 원본 소리를 버리고 내레이션만 — 낮춰도 영어가 새어 나온다(힐스테이트 글자 훅 「영어 내레이션」 · 2026-09-17 사장님) · 말소리 없는 원본(음악만)은 남기고 덕킹
|
|
319
|
+
const replace = hasSpeech;
|
|
320
320
|
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0.2 : speakingBoard ? 0.45 : 0.6, replace });
|
|
321
321
|
fs.copyFileSync(mixed, rawFile);
|
|
322
322
|
costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
|
|
@@ -20,6 +20,13 @@ export declare function onColor(hex: string): string;
|
|
|
20
20
|
/** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
|
|
21
21
|
/** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
|
|
22
22
|
export declare function cutWords(text: string, limit: number): string;
|
|
23
|
+
/** 글자 폭 어림(Pretendard 실측 · 에어서울 97px 헤드라인 780px): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
|
|
24
|
+
export declare const textW: (t: string, fs: number) => number;
|
|
25
|
+
/** 헤드라인 줄나눔 — 「오늘의 입술, 틴트 한 / 번」·「나에게 맞는 컬러 / 찾기」처럼 넘치는 자리에서 끊겨 한 어절이 떨어지는 것을 막는다(2026-09-17 사장님).
|
|
26
|
+
* 규칙: 한 줄에 들어가면 그대로 · 두 줄이면 모든 어절 경계 후보 중 (① 쉼표·물음표·마침표·가운뎃점 뒤 우선 ② 두 줄 길이 균형 ③ 마지막 줄이 한 어절(≤4wlen)이면 강한 벌점 ④ 두 줄 모두 폭 안) 비용 최소 · 세 줄 이상은 뒤에서부터 채워 마지막 줄 고아를 피한다 */
|
|
27
|
+
export declare function breakLines(text: string, fs: number, maxW: number, maxLines?: number): string[];
|
|
28
|
+
/** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
|
|
29
|
+
export declare function linesEl(text: string, style: Record<string, unknown>, maxW: number, align?: 'left' | 'center'): El;
|
|
23
30
|
export declare function wordsEl(text: string, style: Record<string, unknown>, align?: 'left' | 'center'): El;
|
|
24
31
|
/** 한 규격의 트리 */
|
|
25
32
|
export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
|
|
@@ -107,6 +107,67 @@ export function cutWords(text, limit) {
|
|
|
107
107
|
const sp = cut.lastIndexOf(' ');
|
|
108
108
|
return (sp > limit * 0.5 ? cut.slice(0, sp) : t.slice(0, limit)).replace(/[\s,·:;\-—]+$/, '');
|
|
109
109
|
}
|
|
110
|
+
/** 글자 폭 어림(Pretendard 실측 · 에어서울 97px 헤드라인 780px): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
|
|
111
|
+
export const textW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 : (ch.codePointAt(0) || 0) > 0x2e7f ? 0.86 : 0.55), 0) * fs;
|
|
112
|
+
/** 헤드라인 줄나눔 — 「오늘의 입술, 틴트 한 / 번」·「나에게 맞는 컬러 / 찾기」처럼 넘치는 자리에서 끊겨 한 어절이 떨어지는 것을 막는다(2026-09-17 사장님).
|
|
113
|
+
* 규칙: 한 줄에 들어가면 그대로 · 두 줄이면 모든 어절 경계 후보 중 (① 쉼표·물음표·마침표·가운뎃점 뒤 우선 ② 두 줄 길이 균형 ③ 마지막 줄이 한 어절(≤4wlen)이면 강한 벌점 ④ 두 줄 모두 폭 안) 비용 최소 · 세 줄 이상은 뒤에서부터 채워 마지막 줄 고아를 피한다 */
|
|
114
|
+
export function breakLines(text, fs, maxW, maxLines = 3) {
|
|
115
|
+
const raw = String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
116
|
+
// 한 글자 어절(「한」「다」「그」)은 다음 어절과 한 덩어리 — 「틴트 한 / 번」 금지
|
|
117
|
+
const words = [];
|
|
118
|
+
for (let i = 0; i < raw.length; i++) {
|
|
119
|
+
if ([...raw[i]].length === 1 && /[가-힣]/.test(raw[i]) && i + 1 < raw.length) {
|
|
120
|
+
words.push(raw[i] + ' ' + raw[i + 1]);
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
else
|
|
124
|
+
words.push(raw[i]);
|
|
125
|
+
}
|
|
126
|
+
if (!words.length)
|
|
127
|
+
return [];
|
|
128
|
+
const W = (ws) => textW(ws.join(' '), fs);
|
|
129
|
+
if (words.length === 1 || W(words) <= maxW)
|
|
130
|
+
return [words.join(' ')];
|
|
131
|
+
const orphan = (ws) => ws.length === 1 && wlen(ws[0].replace(' ', '')) <= 4;
|
|
132
|
+
let best = null;
|
|
133
|
+
for (let i = 1; i < words.length; i++) {
|
|
134
|
+
const a = words.slice(0, i), b = words.slice(i);
|
|
135
|
+
const wa = W(a), wb = W(b);
|
|
136
|
+
if (wa > maxW || wb > maxW)
|
|
137
|
+
continue;
|
|
138
|
+
let cost = Math.abs(wa - wb) / maxW;
|
|
139
|
+
if (/[,,、.!?!?:·—-]$/.test(a[a.length - 1]))
|
|
140
|
+
cost -= 0.5;
|
|
141
|
+
if (orphan(b) || orphan(a))
|
|
142
|
+
cost += 1;
|
|
143
|
+
if (!best || cost < best.cost)
|
|
144
|
+
best = { lines: [a.join(' '), b.join(' ')], cost };
|
|
145
|
+
}
|
|
146
|
+
if (best)
|
|
147
|
+
return best.lines;
|
|
148
|
+
// 세 줄 이상 — 뒤에서부터 채워 마지막 줄이 짧게 남지 않게
|
|
149
|
+
const lines = [];
|
|
150
|
+
let cur = [];
|
|
151
|
+
for (let i = words.length - 1; i >= 0; i--) {
|
|
152
|
+
const next = [words[i], ...cur];
|
|
153
|
+
if (cur.length && W(next) > maxW) {
|
|
154
|
+
lines.unshift(cur);
|
|
155
|
+
cur = [words[i]];
|
|
156
|
+
}
|
|
157
|
+
else
|
|
158
|
+
cur = next;
|
|
159
|
+
}
|
|
160
|
+
if (cur.length)
|
|
161
|
+
lines.unshift(cur);
|
|
162
|
+
return lines.slice(0, maxLines).map((l) => l.join(' '));
|
|
163
|
+
}
|
|
164
|
+
/** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
|
|
165
|
+
export function linesEl(text, style, maxW, align = 'left') {
|
|
166
|
+
const lines = breakLines(text, Number(style.fontSize || 16), maxW);
|
|
167
|
+
if (lines.length <= 1)
|
|
168
|
+
return wordsEl(text, style, align);
|
|
169
|
+
return h('div', { display: 'flex', flexDirection: 'column', alignItems: align === 'center' ? 'center' : 'flex-start', ...style }, lines.map((l) => h('div', { whiteSpace: 'nowrap' }, l)));
|
|
170
|
+
}
|
|
110
171
|
export function wordsEl(text, style, align = 'left') {
|
|
111
172
|
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
112
173
|
// 🔴 일본어·중국어는 띄어쓰기가 없어 한 어절이 줄 전체보다 길다 → nowrap 조각이 오른쪽으로 잘림(ISEKAI'D 일본어 엔드카드 · 2026-09-17) → 글자 단위로 줄바꿈
|
|
@@ -163,9 +224,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
163
224
|
// 세로 예산 — 헤드라인이 두 줄이면 CTA 가 바닥 밖으로 밀린다(힐스테이트 1200×628 실사고 · 2026-09-17) → 줄 수를 어림해 넘치면 헤드라인 축소 → 알약 줄이기 → 본문 한 줄 순으로 맞춘다
|
|
164
225
|
const colW = Math.round(w * 0.72);
|
|
165
226
|
const gap = Math.round(pad * 0.5);
|
|
166
|
-
|
|
167
|
-
const textW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 : (ch.codePointAt(0) || 0) > 0x2e7f ? 0.86 : 0.55), 0) * fs;
|
|
168
|
-
const lines = (t, fs) => Math.max(1, Math.ceil(textW(t, fs) / colW));
|
|
227
|
+
const lines = (t, fs) => Math.max(1, breakLines(t, fs, colW).length);
|
|
169
228
|
const chipRows = () => { let rows = 0, x = 0; for (const t of proofs) {
|
|
170
229
|
const cw = textW(t, chipFs) + chipFs * 2.4;
|
|
171
230
|
if (!rows || x + cw > colW) {
|
|
@@ -203,14 +262,14 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
203
262
|
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: `0 ${pad}px`, gap: pad }, [brandRow, h('div', { flex: 1, fontSize: headFs, fontWeight: 800, color: fg, letterSpacing: -1, lineHeight: 1.1, whiteSpace: 'nowrap', overflow: 'hidden' }, head), ctaPill]));
|
|
204
263
|
}
|
|
205
264
|
else if (narrow) {
|
|
206
|
-
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [
|
|
265
|
+
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [linesEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.15, letterSpacing: -0.8 }, w - pad * 2), wordsEl(cutWords(body, 60), { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4 })]), ctaPill]));
|
|
207
266
|
}
|
|
208
267
|
else {
|
|
209
268
|
const isWide = w / hh > 1.6;
|
|
210
269
|
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [
|
|
211
270
|
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', {}, '')]),
|
|
212
271
|
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 }, [
|
|
213
|
-
|
|
272
|
+
linesEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }, isWide ? Math.round(w * 0.72) : w - pad * 2),
|
|
214
273
|
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }),
|
|
215
274
|
...(chips ? [chips] : []),
|
|
216
275
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
@@ -277,7 +336,7 @@ export async function renderOverlay(file, w, hh, text, o) {
|
|
|
277
336
|
: h('div', { display: 'flex', backgroundColor: 'rgba(11,21,38,.62)', borderRadius: Math.round(fs1 * 0.4), padding: `${Math.round(fs1 * 0.35)}px ${Math.round(fs1 * 0.6)}px`, maxWidth: Math.round(w * 0.86) }, [wordsEl(text, { fontSize: fs1, fontWeight: 800, color: '#fff', lineHeight: 1.25, letterSpacing: -1 }, 'center')]);
|
|
278
337
|
// 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
|
|
279
338
|
const hook = o.kind === 'hook';
|
|
280
|
-
const hookEl = h('div', { display: 'flex', maxWidth: Math.round(w * 0.86), backgroundColor: hexA(o.primary, 0.92), padding: `${Math.round(base * 0.035)}px ${Math.round(base * 0.05)}px`, borderRadius: Math.round(base * 0.03), transform: 'rotate(-2deg)' }, [
|
|
339
|
+
const hookEl = h('div', { display: 'flex', maxWidth: Math.round(w * 0.86), backgroundColor: hexA(o.primary, 0.92), padding: `${Math.round(base * 0.035)}px ${Math.round(base * 0.05)}px`, borderRadius: Math.round(base * 0.03), transform: 'rotate(-2deg)' }, [linesEl(text, { fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)' }, Math.round(w * 0.86) - Math.round(base * 0.05) * 2, 'center')]);
|
|
281
340
|
const el = hook
|
|
282
341
|
? (o.layout === 'lower'
|
|
283
342
|
? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
|
|
@@ -16,10 +16,19 @@ export const langName = (l) => LANG_NAME[l] || LANG_NAME[l.split('-')[0]] || l;
|
|
|
16
16
|
/** 훅 길이 제한 — 한글·전각 12자(wlen 24) · 라틴은 단어 경계에서 자른다(잘린 단어 금지) */
|
|
17
17
|
export function fitHook(t) {
|
|
18
18
|
const s = t.trim().replace(/\s+/g, ' ');
|
|
19
|
-
if (wlen(s) <=
|
|
19
|
+
if (wlen(s) <= 30)
|
|
20
20
|
return s;
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// 🔴 한글도 어절 경계에서 — 글자 단위로 자르면 「현대건설이 짓는, 힐스테이」(힐스테이트 실사고 · 2026-09-17). 한도 15자(wlen 30) · 줄나눔은 렌더가 맡는다
|
|
22
|
+
if (/[\u3000-\u9fff\uac00-\ud7af]/.test(s)) {
|
|
23
|
+
const ws = s.split(' ');
|
|
24
|
+
let out = '';
|
|
25
|
+
for (const w of ws) {
|
|
26
|
+
if (wlen((out + ' ' + w).trim()) > 30)
|
|
27
|
+
break;
|
|
28
|
+
out = (out + ' ' + w).trim();
|
|
29
|
+
}
|
|
30
|
+
return (out || fit(s, 30)).replace(/[\s,·:;\-—]+$/, '');
|
|
31
|
+
}
|
|
23
32
|
const words = s.split(' ');
|
|
24
33
|
let out = '';
|
|
25
34
|
for (const w of words) {
|
|
@@ -88,7 +97,7 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
88
97
|
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 }; });
|
|
89
98
|
const caps = b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3);
|
|
90
99
|
// 🔴 장면 포맷의 voice 를 모델이 비우면 내레이션 없는 「음악만」 영상이 된다(힐스테이트 글자 훅 · 2026-09-17) → 자막 두 줄로 내레이션을 만든다
|
|
91
|
-
const voice = b.voice?.trim() || (p.format !== 'ugc_selfie' ? [caps[0], caps[1]].filter(Boolean).
|
|
100
|
+
const voice = b.voice?.trim() || (p.format !== 'ugc_selfie' ? [caps[0], caps[1]].filter(Boolean).map((c, i, a) => i < a.length - 1 && !/[.!?。!?,]$/.test(c) ? c + ',' : c).join(' ').replace(/[,]?$/, '').replace(/[^.!?。!?]$/, (m) => m + '.') : undefined);
|
|
92
101
|
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 });
|
|
93
102
|
}
|
|
94
103
|
return boards;
|
package/package.json
CHANGED