adyou 0.5.0 → 0.5.2
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/brief.js +4 -3
- package/dist/core/creatives/factory.js +70 -20
- package/dist/core/creatives/render.d.ts +4 -1
- package/dist/core/creatives/render.js +10 -4
- package/dist/core/creatives/storyboard.d.ts +2 -1
- package/dist/core/creatives/storyboard.js +6 -6
- package/dist/core/creatives/video.js +2 -1
- package/dist/core/creatives/videofx.d.ts +8 -1
- package/dist/core/creatives/videofx.js +59 -6
- package/dist/core/site.js +31 -1
- package/package.json +1 -1
package/dist/core/brief.js
CHANGED
|
@@ -5,12 +5,13 @@ import { INDUSTRY_KEYS } from './creatives/playbook.js';
|
|
|
5
5
|
import { readSite } from './site.js';
|
|
6
6
|
/** 특별 광고 카테고리(정치·금융·주택·고용·의료·도박 등)는 자동 심사 통과가 어려워 접수 거절 */
|
|
7
7
|
const SPECIAL = [
|
|
8
|
-
|
|
8
|
+
// 🔴 넓은 단어 매칭은 오탐(항공 「성인 1명」·여행자 보험 안내·「신용카드 결제」) → 상품 문맥이 붙을 때만
|
|
9
|
+
[/대출|카드론|신용대출|주식\s*투자|코인\s*거래|암호화폐|보험\s*(가입|상품|료|설계)|\bloan|crypto|forex|insurance\s*(plan|quote)|투자\s*(상품|수익)/i, '금융(대출·투자·보험·암호화폐)'],
|
|
9
10
|
[/카지노|베팅|도박|casino|betting|gambl/i, '도박'],
|
|
10
11
|
[/정당|선거|후보|political|election/i, '정치·선거'],
|
|
11
|
-
[
|
|
12
|
+
[/처방|치료제|의약품|병원\s*(진료|예약|시술)|성형|시술\s*(예약|비용)|clinic|pharma|treatment/i, '의료·의약'],
|
|
12
13
|
[/담배|전자담배|vape|tobacco/i, '담배'],
|
|
13
|
-
[
|
|
14
|
+
[/성인\s*(용품|콘텐츠|채널|사이트|영상|방송)|adult\s*(content|toys?|site)|19금|청소년\s*이용\s*불가/i, '성인'],
|
|
14
15
|
];
|
|
15
16
|
export function detectSpecialCategory(text) { for (const [r, n] of SPECIAL)
|
|
16
17
|
if (r.test(text))
|
|
@@ -9,7 +9,7 @@ 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, probe, uiDemoClip } from './videofx.js';
|
|
12
|
+
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, pickHookBand, probe, tailLoudnessDb, uiDemoClip } from './videofx.js';
|
|
13
13
|
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
|
|
14
14
|
export async function renderEndCard(dir, brief, concept, ratio, lang, backgrounds = {}) {
|
|
15
15
|
try {
|
|
@@ -25,16 +25,18 @@ export async function renderEndCard(dir, brief, concept, ratio, lang, background
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
/** 자막 오버레이 묶음 — 훅(0~2.4초 · 상단) → 자막 줄(균등) → CTA(마지막 구간) */
|
|
28
|
-
async function overlaysFor(dir, key, dims, text, cta, primary, durationSec, lang) {
|
|
28
|
+
async function overlaysFor(dir, key, dims, text, cta, primary, durationSec, lang, layout = 'top') {
|
|
29
29
|
const out = [];
|
|
30
30
|
const hookEnd = Math.min(2.4, durationSec * 0.3);
|
|
31
|
+
// 사람 얼굴이 나오는 포맷은 훅을 상단(얼굴 위)이 아니라 가슴 아래(자막 자리)에 — 얼굴을 가리지 않게
|
|
31
32
|
if (text.hook)
|
|
32
|
-
out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_hook.png`), dims.w, dims.h, text.hook, { kind: 'hook', primary, lang }), start: 0, end: hookEnd });
|
|
33
|
-
|
|
33
|
+
out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_hook.png`), dims.w, dims.h, text.hook, { kind: 'hook', primary, lang, layout }), start: 0, end: hookEnd });
|
|
34
|
+
// 훅과 같은 문장인 자막 줄은 빼고(중복) · 훅이 자막 자리(lower)에 있으면 겹치지 않게 훅이 끝난 뒤 시작
|
|
35
|
+
const norm = (t) => t.replace(/[\s.,!?、。!?「」"']/g, '').toLowerCase();
|
|
36
|
+
const lines = text.captions.filter(Boolean).filter((t) => norm(t) !== norm(text.hook)).slice(0, 3);
|
|
34
37
|
if (lines.length) {
|
|
35
|
-
const from = text.hook ? hookEnd - 0.2 : 0;
|
|
38
|
+
const from = text.hook ? (layout === 'lower' ? hookEnd + 0.05 : hookEnd - 0.2) : 0;
|
|
36
39
|
const seg = (durationSec - from) / lines.length;
|
|
37
|
-
lines.forEach((t, i) => { void t; });
|
|
38
40
|
for (const [i, t] of lines.entries())
|
|
39
41
|
out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_cap${i}.png`), dims.w, dims.h, t, { kind: 'line', primary, lang }), start: from + i * seg, end: from + (i + 1) * seg - (i === lines.length - 1 ? 0 : 0.1) });
|
|
40
42
|
}
|
|
@@ -72,14 +74,22 @@ export async function produceConceptVideos(o) {
|
|
|
72
74
|
const board = c.storyboards.find((b) => b.format === format) || c.storyboards[0];
|
|
73
75
|
const dims = VIDEO_DIMS[job.ratio];
|
|
74
76
|
const key = `${c.key}_${format}`;
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const speaks = board.clips.some((cl) => cl.kind === 'gen' && cl.speech);
|
|
78
|
+
// 말하는 포맷(UGC)은 언어마다 그 언어 대사로 클립을 따로 생성(🔴 자막만 바꾸면 음성은 원어 그대로 — 2026-09-16 사장님 검수) · 말하지 않는 포맷은 원본 하나를 언어별 자막으로 공유
|
|
79
|
+
const rawLangs = speaks ? langs : [langs[0]];
|
|
80
|
+
const costByLang = {};
|
|
81
|
+
const rawFor = (lang) => path.join(o.dir, speaks ? `${key}_${lang}_${job.ratio}.raw.mp4` : `${key}_${job.ratio}.raw.mp4`);
|
|
82
|
+
for (const rl of rawLangs) {
|
|
83
|
+
const rawFile = rawFor(rl);
|
|
84
|
+
let costKrw = 0;
|
|
85
|
+
if (fs.existsSync(rawFile))
|
|
86
|
+
continue;
|
|
87
|
+
const lb0 = localizedBoard(board, c.i18n, rl);
|
|
88
|
+
log(`「${c.name}」 ${format} 영상 ${job.ratio.replace('x', ':')} ${job.durationSec}초${speaks ? ` · ${langName(rl)} 대사` : ''} — 훅 「${lb0.hook}」 (${job.provider === 'veo' ? 'Veo 3.1' : 'Seedance 2.5'} · 장르 ${board.genre})`);
|
|
79
89
|
const clipFiles = [];
|
|
80
90
|
let failed = false;
|
|
81
91
|
for (const [ci, clip] of board.clips.entries()) {
|
|
82
|
-
const cf = path.join(o.dir, `${key}_${job.ratio}_c${ci}.mp4`);
|
|
92
|
+
const cf = path.join(o.dir, `${key}_${rl}_${job.ratio}_c${ci}.mp4`);
|
|
83
93
|
try {
|
|
84
94
|
if (clip.kind === 'ui_demo') {
|
|
85
95
|
if (!ff)
|
|
@@ -95,11 +105,40 @@ export async function produceConceptVideos(o) {
|
|
|
95
105
|
}
|
|
96
106
|
else {
|
|
97
107
|
const person = format === 'ugc_selfie';
|
|
98
|
-
const
|
|
108
|
+
const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
|
|
109
|
+
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
110
|
+
const speech = line ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}, not English unless the line is English): "${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).` : '';
|
|
99
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.`;
|
|
100
112
|
const firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
|
|
101
|
-
|
|
113
|
+
let r;
|
|
114
|
+
try {
|
|
115
|
+
r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model: job.model, 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
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (!e.retryable)
|
|
119
|
+
throw e;
|
|
120
|
+
costKrw += Number(e.costKrw || 0);
|
|
121
|
+
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: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
|
|
123
|
+
}
|
|
102
124
|
costKrw += r.costKrw;
|
|
125
|
+
// 대사 꼬리 검사 — 마지막 0.7초가 아직 시끄러우면(≥ -27dB) 말이 끝까지 이어진 것 → 더 짧은 대사로 1회 재생성(원가 1회 추가)
|
|
126
|
+
if (line && ff) {
|
|
127
|
+
const tail = await tailLoudnessDb(cf, 0.7);
|
|
128
|
+
if (tail >= -27) {
|
|
129
|
+
log(` 대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB) → 더 짧게 한 번 더 만들어요`);
|
|
130
|
+
const short = line.split(/(?<=[.!?。!?])\s+/)[0] || line;
|
|
131
|
+
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: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
|
|
133
|
+
costKrw += r2.costKrw;
|
|
134
|
+
const t2 = await tailLoudnessDb(cf, 0.7);
|
|
135
|
+
log(` 다시 만든 컷 꼬리 ${t2.toFixed(0)}dB${t2 >= -27 ? ' (여전히 이어짐 · 그대로 사용)' : ' ✓'}`);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
log(` ⚠ 재생성 실패(첫 테이크 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
103
142
|
log(` 컷 ${ci + 1}/${board.clips.length} ${clip.sec}초 ✓ 원가 ₩${Math.round(r.costKrw).toLocaleString()}`);
|
|
104
143
|
}
|
|
105
144
|
clipFiles.push(cf);
|
|
@@ -114,9 +153,10 @@ export async function produceConceptVideos(o) {
|
|
|
114
153
|
break;
|
|
115
154
|
}
|
|
116
155
|
}
|
|
156
|
+
costByLang[rl] = costKrw;
|
|
117
157
|
if (failed || !clipFiles.length) {
|
|
118
158
|
if (costKrw > 0)
|
|
119
|
-
await o.onAsset?.({ concept: c.key, w: 0, h: 0, ratio: job.ratio, file: '', medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw, origin: 'ai', format });
|
|
159
|
+
await o.onAsset?.({ concept: c.key, w: 0, h: 0, ratio: job.ratio, file: '', medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw, origin: 'ai', format, lang: rl });
|
|
120
160
|
continue;
|
|
121
161
|
}
|
|
122
162
|
try {
|
|
@@ -131,11 +171,16 @@ export async function produceConceptVideos(o) {
|
|
|
131
171
|
}
|
|
132
172
|
}
|
|
133
173
|
// 3) 언어 × 비율 마감 — 원본(raw)에서 비율을 먼저 만들고 그 위에 자막·엔드카드
|
|
134
|
-
const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
|
|
135
174
|
for (const lang of langs) {
|
|
175
|
+
const rawFile = rawFor(lang);
|
|
176
|
+
if (!fs.existsSync(rawFile)) {
|
|
177
|
+
log(` ⚠ ${lang} 원본이 없어 건너뜀`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
136
180
|
const lb = localizedBoard(board, c.i18n, lang);
|
|
137
181
|
const lc = localizedConcept(c, c.i18n, lang);
|
|
138
182
|
const cta = ctaLabel(c.cta, lang);
|
|
183
|
+
const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
|
|
139
184
|
for (const ratio of ratios) {
|
|
140
185
|
const d = VIDEO_DIMS[ratio];
|
|
141
186
|
const finalFile = path.join(o.dir, `${key}_${lang}_${ratio}.mp4`);
|
|
@@ -146,14 +191,18 @@ export async function produceConceptVideos(o) {
|
|
|
146
191
|
const pr0 = await probe(rawFile);
|
|
147
192
|
durationSec = pr0.durationSec;
|
|
148
193
|
if (ratio !== job.ratio) {
|
|
149
|
-
const df = path.join(o.dir, `${key}_${ratio}.raw.mp4`);
|
|
194
|
+
const df = path.join(o.dir, `${key}_${speaks ? lang + '_' : ''}${ratio}.raw.mp4`);
|
|
150
195
|
if (!fs.existsSync(df))
|
|
151
196
|
await deriveRatio(rawFile, df, ratio);
|
|
152
197
|
cur = df;
|
|
153
198
|
}
|
|
154
199
|
if (job.captions) {
|
|
200
|
+
// 훅 자리: 사람 포맷은 가슴 아래 · 그 외는 첫 프레임 분석(피사체가 있는 띠를 피함 · 상단 우선)
|
|
201
|
+
const layout = format === 'ugc_selfie' || format === 'lifestyle' ? 'lower' : await pickHookBand(cur);
|
|
202
|
+
if (lang === langs[0] && ratio === job.ratio)
|
|
203
|
+
log(` 훅 자리: ${layout === 'top' ? '상단' : layout === 'middle' ? '중앙' : '하단'}(첫 프레임 분석)`);
|
|
155
204
|
const withCap = path.join(o.dir, `${key}_${lang}_${ratio}.cap.mp4`);
|
|
156
|
-
await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang) });
|
|
205
|
+
await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang, layout) });
|
|
157
206
|
cur = withCap;
|
|
158
207
|
}
|
|
159
208
|
if (job.endCard) {
|
|
@@ -171,13 +220,14 @@ export async function produceConceptVideos(o) {
|
|
|
171
220
|
}
|
|
172
221
|
}
|
|
173
222
|
fs.copyFileSync(cur, finalFile);
|
|
174
|
-
const primary =
|
|
175
|
-
const asset = { concept: c.key, w: d.w, h: d.h, ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: primary ?
|
|
223
|
+
const primary = ratio === job.ratio && (speaks ? true : lang === langs[0]);
|
|
224
|
+
const asset = { concept: c.key, w: d.w, h: d.h, ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: primary ? (costByLang[speaks ? lang : langs[0]] || 0) : 0, origin: 'ai', format, lang, variant: `${format}_${lang}` };
|
|
176
225
|
out.push(asset);
|
|
177
226
|
await o.onAsset?.(asset);
|
|
178
227
|
}
|
|
179
228
|
}
|
|
180
|
-
|
|
229
|
+
const costKrw = Object.values(costByLang).reduce((n, v) => n + v, 0);
|
|
230
|
+
log(` ${format} 완성 — 언어 ${langs.join('·')}${speaks ? '(언어별 대사 클립)' : '(자막만 언어별)'} × 비율 ${[job.ratio, ...DERIVED_RATIOS[job.ratio]].map((r) => r.replace('x', ':')).join('·')}${costKrw ? ` · 생성 원가 ₩${Math.round(costKrw).toLocaleString()}` : ''}`);
|
|
181
231
|
}
|
|
182
232
|
return out;
|
|
183
233
|
}
|
|
@@ -15,6 +15,8 @@ type El = {
|
|
|
15
15
|
type: string;
|
|
16
16
|
props: Record<string, unknown>;
|
|
17
17
|
};
|
|
18
|
+
/** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
|
|
19
|
+
export declare function onColor(hex: string): string;
|
|
18
20
|
/** 한 규격의 트리 */
|
|
19
21
|
export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
|
|
20
22
|
export declare const CTA_LABEL: Record<string, Record<string, string>>;
|
|
@@ -31,7 +33,8 @@ export declare function renderLogo(dir: string, brief: Brief): Promise<CreativeA
|
|
|
31
33
|
export declare function renderOverlay(file: string, w: number, hh: number, text: string, o: {
|
|
32
34
|
kind: 'line' | 'cta' | 'hook';
|
|
33
35
|
primary: string;
|
|
34
|
-
lang?: string;
|
|
36
|
+
lang?: string; /** 훅 위치 — top(기본) · lower(사람 얼굴이 나오는 포맷: 가슴 아래 · 자막 자리) */
|
|
37
|
+
layout?: 'top' | 'middle' | 'lower';
|
|
35
38
|
}): Promise<string>;
|
|
36
39
|
/** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
|
|
37
40
|
export declare function renderPhoneStage(dir: string, w: number, hh: number, primary: string, dark: string): Promise<{
|
|
@@ -95,6 +95,8 @@ async function fetchLogo(url, dir) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
const h = (type, style, children, extra = {}) => ({ type, props: { style, ...extra, ...(children === undefined ? {} : { children }) } });
|
|
98
|
+
/** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
|
|
99
|
+
export function onColor(hex) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; return L > 0.62 ? '#0b1526' : '#ffffff'; }
|
|
98
100
|
function hexA(hex, a) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; }
|
|
99
101
|
/** 한 규격의 트리 */
|
|
100
102
|
export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
@@ -128,7 +130,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
128
130
|
const brandRow = h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [
|
|
129
131
|
logo ? h('img', { height: short ? Math.round(hh * 0.5) : Math.round(headFs * 0.9), borderRadius: 6 }, undefined, { src: logo }) : h('div', { fontSize: Math.round(headFs * 0.42), fontWeight: 800, color: fg, letterSpacing: -0.5 }, brief.company),
|
|
130
132
|
]);
|
|
131
|
-
const ctaPill = h('div', { display: 'flex', alignItems: 'center', fontSize: ctaFs, fontWeight: 700, color:
|
|
133
|
+
const ctaPill = h('div', { display: 'flex', alignItems: 'center', fontSize: ctaFs, fontWeight: 700, color: onColor(primary), backgroundColor: primary, borderRadius: 999, padding: `${Math.round(ctaFs * 0.5)}px ${Math.round(ctaFs * 1.1)}px`, ...(banner ? {} : { boxShadow: `0 10px 30px ${hexA(primary, 0.45)}` }) }, ctaText);
|
|
132
134
|
if (short) {
|
|
133
135
|
// 가로 띠: 로고 | 헤드라인 | CTA
|
|
134
136
|
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]));
|
|
@@ -202,13 +204,17 @@ export async function renderOverlay(file, w, hh, text, o) {
|
|
|
202
204
|
const base = Math.min(w, hh);
|
|
203
205
|
const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
|
|
204
206
|
const pill = o.kind === 'cta'
|
|
205
|
-
? h('div', { display: 'flex', fontSize: fs1, fontWeight: 700, color:
|
|
207
|
+
? h('div', { display: 'flex', fontSize: fs1, fontWeight: 700, color: onColor(o.primary), backgroundColor: o.primary, borderRadius: 999, padding: `${Math.round(fs1 * 0.55)}px ${Math.round(fs1 * 1.3)}px`, boxShadow: `0 10px 30px ${hexA(o.primary, 0.45)}` }, text)
|
|
206
208
|
: h('div', { display: 'flex', fontSize: fs1, fontWeight: 800, color: '#fff', 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`, lineHeight: 1.25, letterSpacing: -1, textAlign: 'center', maxWidth: Math.round(w * 0.86), wordBreak: 'keep-all' }, text);
|
|
207
209
|
// 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
|
|
208
210
|
const hook = o.kind === 'hook';
|
|
209
|
-
const hookEl = h('div', { display: 'flex', fontSize: Math.round(base * 0.095), fontWeight: 800, color:
|
|
211
|
+
const hookEl = h('div', { display: 'flex', fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textAlign: 'center', maxWidth: Math.round(w * 0.86), wordBreak: 'keep-all', textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)', 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)' }, text);
|
|
210
212
|
const el = hook
|
|
211
|
-
?
|
|
213
|
+
? (o.layout === 'lower'
|
|
214
|
+
? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
|
|
215
|
+
: o.layout === 'middle'
|
|
216
|
+
? h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: FONT_FAMILY }, [hookEl])
|
|
217
|
+
: h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), fontFamily: FONT_FAMILY }, [hookEl]))
|
|
212
218
|
: h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * (o.kind === 'cta' ? 0.12 : 0.24)), fontFamily: FONT_FAMILY }, [pill]);
|
|
213
219
|
const svg = await satori(el, { width: w, height: hh, fonts });
|
|
214
220
|
fs.writeFileSync(file, new Resvg(svg, { fitTo: { mode: 'width', value: w } }).render().asPng());
|
|
@@ -24,7 +24,8 @@ export type Storyboard = {
|
|
|
24
24
|
export type LocalizedBoard = {
|
|
25
25
|
hook: string;
|
|
26
26
|
captions: string[];
|
|
27
|
-
voice?: string;
|
|
27
|
+
voice?: string; /** 말하는 포맷(UGC)의 대사 — 언어별 클립을 따로 생성할 때 쓴다 */
|
|
28
|
+
speech?: string;
|
|
28
29
|
};
|
|
29
30
|
export type LocalizedCopy = {
|
|
30
31
|
headlines: string[];
|
|
@@ -57,7 +57,7 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
57
57
|
규칙:
|
|
58
58
|
- boards 는 요청 포맷마다 정확히 1개. 각 board: format · genre(위 장르 중 하나) · hook(첫 프레임 큰 글자 · ${lang} · 6단어/12자 이내 · 질문/숫자/반전) · captions(화면 자막 3줄 · ${lang} · 각 18자 이내 · 훅→가치/증명→행동 순 · 마지막 줄은 행동 유도) · voice(선택 · ${lang} 한 문장 내레이션/대사) · music(영어 한 줄).
|
|
59
59
|
- clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
|
|
60
|
-
- ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는
|
|
60
|
+
- ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 한 문장(영어 ≤ 12단어 · 한국어/일본어 ≤ 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다(뚝 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
|
|
61
61
|
- ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
|
|
62
62
|
- 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
|
|
63
63
|
- 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
|
|
@@ -118,9 +118,9 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
118
118
|
return out;
|
|
119
119
|
}
|
|
120
120
|
const r = await completeJson({
|
|
121
|
-
schema: z.object({ translations: z.record(z.string(), z.object({ headlines: z.array(z.string()), bodies: z.array(z.string()), descriptions: z.array(z.string()), boards: z.array(z.object({ id: z.string(), hook: z.string(), captions: z.array(z.string()), voice: z.string().optional().nullable() })).optional() })) }), maxTokens: 6000, timeoutMs: 150_000,
|
|
122
|
-
system: `너는 광고 현지화 카피라이터다. 아래 문구·자막·훅·대사를 각 언어로 옮긴다. 직역이 아니라 그 시장 광고에서 자연스러운 표현으로, 뜻·사실·숫자는 그대로. 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자(한글·전각 2자) · 설명 ≤ ${GOOGLE_LIMITS.description}자 · 자막 각 18자(영문 32자) 이내 · 훅 6단어
|
|
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 })) }),
|
|
121
|
+
schema: z.object({ translations: z.record(z.string(), z.object({ headlines: z.array(z.string()), bodies: z.array(z.string()), descriptions: z.array(z.string()), boards: z.array(z.object({ id: z.string(), hook: z.string(), captions: z.array(z.string()), voice: z.string().optional().nullable(), speech: z.string().optional().nullable() })).optional() })) }), maxTokens: 6000, timeoutMs: 150_000,
|
|
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
|
+
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
125
|
for (const l of targets) {
|
|
126
126
|
const t = r.translations[l] || r.translations[l.split('-')[0]];
|
|
@@ -130,7 +130,7 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
130
130
|
for (const b of boards) {
|
|
131
131
|
const tb = t.boards?.find((x) => x.id === b.id);
|
|
132
132
|
if (tb)
|
|
133
|
-
out[l].boards = { ...(out[l].boards || {}), [b.id]: { hook: fitHook(tb.hook), captions: tb.captions.slice(0, 3), voice: tb.voice || undefined } };
|
|
133
|
+
out[l].boards = { ...(out[l].boards || {}), [b.id]: { hook: fitHook(tb.hook), captions: tb.captions.slice(0, 3), voice: tb.voice || undefined, speech: tb.speech || undefined } };
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
return out;
|
|
@@ -138,7 +138,7 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
138
138
|
/** 언어별 콘티 자막/훅 꺼내기(없으면 원문) */
|
|
139
139
|
export function localizedBoard(b, i18n, lang) {
|
|
140
140
|
const t = i18n?.[lang]?.boards?.[b.id];
|
|
141
|
-
return t ? { hook: t.hook, captions: t.captions, voice: t.voice } : { hook: b.hook, captions: b.captions, voice: b.voice };
|
|
141
|
+
return t ? { hook: t.hook, captions: t.captions, voice: t.voice, speech: t.speech } : { hook: b.hook, captions: b.captions, voice: b.voice, speech: b.clips.find((c) => c.speech)?.speech };
|
|
142
142
|
}
|
|
143
143
|
/** 언어별 컨셉(문구 치환) — 이미지 렌더·매체 카피에 */
|
|
144
144
|
export function localizedConcept(c, i18n, lang) {
|
|
@@ -67,8 +67,9 @@ async function veo(i, cfg) {
|
|
|
67
67
|
const s = (await st.json().catch(() => ({})));
|
|
68
68
|
if (s.cost_krw)
|
|
69
69
|
costKrw = s.cost_krw;
|
|
70
|
+
// 🔴 Veo 「No video in response」 = 안전 필터(실사 인물·식품 등)로 결과가 비어 옴 · 접수비는 발생 → 호출자가 다른 테이크로 1회 재시도(retryable)
|
|
70
71
|
if (s.status === 'failed')
|
|
71
|
-
throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw });
|
|
72
|
+
throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw, retryable: /No video in response|filtered|safety/i.test(String(s.error || '')) });
|
|
72
73
|
if (s.status === 'completed') {
|
|
73
74
|
const dl = await fetch(`${cfg.base}/v1/video/generation/${acc.operation_id}/download`, { headers: H, signal: AbortSignal.timeout(300_000) });
|
|
74
75
|
if (!dl.ok)
|
|
@@ -18,7 +18,7 @@ export declare function burnCaptions(input: string, output: string, o: {
|
|
|
18
18
|
end: number;
|
|
19
19
|
}[];
|
|
20
20
|
}): Promise<void>;
|
|
21
|
-
/** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다.
|
|
21
|
+
/** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 하드컷 대신 소리 페이드아웃(0.6초) + 0.35초 디졸브 — 대사가 끝나는 순간 「뚝」 끊기는 느낌을 줄인다. */
|
|
22
22
|
export declare function appendEndCard(input: string, cardPng: string, output: string, seconds?: number): Promise<void>;
|
|
23
23
|
/** 비율 파생 — 중앙 크롭 후 표준 크기로 스케일(9:16 → 4:5 · 1:1, 16:9 → 1:1) */
|
|
24
24
|
export declare function deriveRatio(input: string, output: string, ratio: VideoRatio): Promise<void>;
|
|
@@ -57,3 +57,10 @@ export declare function kenBurnsClip(image: string, output: string, o: {
|
|
|
57
57
|
h: number;
|
|
58
58
|
};
|
|
59
59
|
}): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* 훅 자막 자리 고르기 — 첫 1초 프레임을 작게(36×64 그레이) 읽어 세 띠(상단 8~34% · 중앙 38~62% · 하단 58~80%)의 디테일(이웃 픽셀 차 합)을 비교해 가장 비어 있는 띠를 고른다.
|
|
62
|
+
* 피사체(얼굴·제품)는 디테일이 높고 하늘·배경은 낮다. 하단은 매체 UI(20%)와 자막 자리와 겹치므로 상단·중앙이 같은 값이면 상단 우선. 실패하면 'top'.
|
|
63
|
+
*/
|
|
64
|
+
export declare function pickHookBand(video: string, atSec?: number): Promise<'top' | 'middle' | 'lower'>;
|
|
65
|
+
/** 마지막 tailSec 초의 평균 음량(dBFS) — 대사가 끝까지 이어져 「뚝」 끊기는지 판정(무음·음악만이면 낮음). 실패 시 -99. */
|
|
66
|
+
export declare function tailLoudnessDb(file: string, tailSec?: number): Promise<number>;
|
|
@@ -54,19 +54,30 @@ export async function burnCaptions(input, output, o) {
|
|
|
54
54
|
const args = ['-y', '-v', 'error', ...inputs, '-filter_complex', fc, '-map', '[v]', ...(p.hasAudio ? ['-map', '0:a', '-c:a', 'copy'] : []), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', output];
|
|
55
55
|
await execFileP('ffmpeg', args, { maxBuffer: 8 << 20 });
|
|
56
56
|
}
|
|
57
|
-
/** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다.
|
|
57
|
+
/** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 하드컷 대신 소리 페이드아웃(0.6초) + 0.35초 디졸브 — 대사가 끝나는 순간 「뚝」 끊기는 느낌을 줄인다. */
|
|
58
58
|
export async function appendEndCard(input, cardPng, output, seconds = 2) {
|
|
59
59
|
const p = await probe(input);
|
|
60
60
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-end-'));
|
|
61
61
|
const card = path.join(dir, 'card.mp4');
|
|
62
|
-
|
|
62
|
+
const xf = 0.35;
|
|
63
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-t', String(seconds + xf), '-i', cardPng, '-f', 'lavfi', '-t', String(seconds + xf), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', `scale=${p.w}:${p.h}:force_original_aspect_ratio=increase,crop=${p.w}:${p.h},format=yuv420p,fps=24`, '-c:v', 'libx264', '-preset', 'veryfast', '-c:a', 'aac', '-shortest', card]);
|
|
63
64
|
const main = path.join(dir, 'main.mp4');
|
|
64
|
-
//
|
|
65
|
+
// 본편 정규화(24fps · yuv420p · 48k 스테레오 · 무음이면 무음 트랙) + 끝 0.6초 소리 페이드아웃
|
|
66
|
+
const d = Math.max(0.5, p.durationSec);
|
|
67
|
+
const fadeSt = Math.max(0, d - 0.6).toFixed(2);
|
|
65
68
|
const args = p.hasAudio
|
|
66
|
-
? ['-y', '-v', 'error', '-i', input, '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', main]
|
|
67
|
-
: ['-y', '-v', 'error', '-i', input, '-f', 'lavfi', '-t', String(
|
|
69
|
+
? ['-y', '-v', 'error', '-i', input, '-vf', 'fps=24,format=yuv420p', '-af', `afade=t=out:st=${fadeSt}:d=0.6`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', main]
|
|
70
|
+
: ['-y', '-v', 'error', '-i', input, '-f', 'lavfi', '-t', String(d), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', main];
|
|
68
71
|
await execFileP('ffmpeg', args, { maxBuffer: 8 << 20 });
|
|
69
|
-
|
|
72
|
+
const md = (await probe(main)).durationSec;
|
|
73
|
+
const offset = Math.max(0.1, md - xf).toFixed(2);
|
|
74
|
+
try {
|
|
75
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', main, '-i', card, '-filter_complex', `[0:v][1:v]xfade=transition=fade:duration=${xf}:offset=${offset}[v];[0:a][1:a]acrossfade=d=${xf}[a]`, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// xfade 가 안 되는 입력(가변 fps 등)이면 하드컷 concat 로 폴백
|
|
79
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', main, '-i', card, '-filter_complex', '[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]', '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
|
|
80
|
+
}
|
|
70
81
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
71
82
|
}
|
|
72
83
|
/** 비율 파생 — 중앙 크롭 후 표준 크기로 스케일(9:16 → 4:5 · 1:1, 16:9 → 1:1) */
|
|
@@ -123,3 +134,45 @@ export async function kenBurnsClip(image, output, o) {
|
|
|
123
134
|
const frames = Math.round(o.sec * fps);
|
|
124
135
|
await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-framerate', String(fps), '-t', String(o.sec), '-i', image, '-f', 'lavfi', '-t', String(o.sec), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', `scale=${o.dims.w * 2}:${o.dims.h * 2}:force_original_aspect_ratio=increase,crop=${o.dims.w * 2}:${o.dims.h * 2},zoompan=z='min(zoom+0.0009,1.12)':d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${o.dims.w}x${o.dims.h}:fps=${fps},format=yuv420p`, '-frames:v', String(frames), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
|
|
125
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* 훅 자막 자리 고르기 — 첫 1초 프레임을 작게(36×64 그레이) 읽어 세 띠(상단 8~34% · 중앙 38~62% · 하단 58~80%)의 디테일(이웃 픽셀 차 합)을 비교해 가장 비어 있는 띠를 고른다.
|
|
139
|
+
* 피사체(얼굴·제품)는 디테일이 높고 하늘·배경은 낮다. 하단은 매체 UI(20%)와 자막 자리와 겹치므로 상단·중앙이 같은 값이면 상단 우선. 실패하면 'top'.
|
|
140
|
+
*/
|
|
141
|
+
export async function pickHookBand(video, atSec = 0.6) {
|
|
142
|
+
try {
|
|
143
|
+
const W = 36, H = 64;
|
|
144
|
+
const { stdout } = await execFileP('ffmpeg', ['-v', 'error', '-ss', String(atSec), '-i', video, '-frames:v', '1', '-vf', `scale=${W}:${H}`, '-pix_fmt', 'gray', '-f', 'rawvideo', '-'], { encoding: 'buffer', maxBuffer: 1 << 20 });
|
|
145
|
+
const px = stdout;
|
|
146
|
+
if (px.length < W * H)
|
|
147
|
+
return 'top';
|
|
148
|
+
const detail = (y0, y1) => { let d = 0, n = 0; for (let y = Math.floor(y0 * H); y < Math.floor(y1 * H) - 1; y++)
|
|
149
|
+
for (let x = 0; x < W - 1; x++) {
|
|
150
|
+
const i = y * W + x;
|
|
151
|
+
d += Math.abs(px[i] - px[i + 1]) + Math.abs(px[i] - px[i + W]);
|
|
152
|
+
n++;
|
|
153
|
+
} return n ? d / n : 0; };
|
|
154
|
+
const bands = { top: detail(0.08, 0.34), middle: detail(0.38, 0.62), lower: detail(0.58, 0.80) };
|
|
155
|
+
const order = ['top', 'middle', 'lower'].sort((a, b) => bands[a] - bands[b]);
|
|
156
|
+
// 상단이 최소이거나 최소와 15% 안이면 상단(관례·안전영역) · 아니면 최소 띠
|
|
157
|
+
const best = order[0];
|
|
158
|
+
if (best === 'top' || bands.top <= bands[best] * 1.15)
|
|
159
|
+
return 'top';
|
|
160
|
+
return best;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return 'top';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** 마지막 tailSec 초의 평균 음량(dBFS) — 대사가 끝까지 이어져 「뚝」 끊기는지 판정(무음·음악만이면 낮음). 실패 시 -99. */
|
|
167
|
+
export async function tailLoudnessDb(file, tailSec = 0.7) {
|
|
168
|
+
try {
|
|
169
|
+
const p = await probe(file);
|
|
170
|
+
const st = Math.max(0, p.durationSec - tailSec);
|
|
171
|
+
const { stderr } = await execFileP('ffmpeg', ['-v', 'info', '-ss', st.toFixed(2), '-i', file, '-vn', '-af', 'volumedetect', '-f', 'null', '-'], { maxBuffer: 4 << 20 });
|
|
172
|
+
const m = /mean_volume:\s*(-?[\d.]+) dB/.exec(String(stderr));
|
|
173
|
+
return m ? Number(m[1]) : -99;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return -99;
|
|
177
|
+
}
|
|
178
|
+
}
|
package/dist/core/site.js
CHANGED
|
@@ -25,8 +25,38 @@ catch {
|
|
|
25
25
|
export function stripHtml(html) {
|
|
26
26
|
return decode(html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
27
27
|
}
|
|
28
|
+
/** 봇 차단(403·429·503)·빈 HTML 이면 헤드리스 크로미움으로 렌더된 DOM 을 받는다(항공·대형 쇼핑몰 · JS 전용 사이트). 크로미움이 없으면 그대로. */
|
|
29
|
+
async function fetchHtmlSmart(url) {
|
|
30
|
+
let first = null;
|
|
31
|
+
try {
|
|
32
|
+
first = await fetchHtml(url);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
first = null;
|
|
36
|
+
}
|
|
37
|
+
const blocked = !first || [401, 403, 429, 503].includes(first.status) || first.html.length < 1500;
|
|
38
|
+
if (!blocked)
|
|
39
|
+
return { ...first, via: 'fetch' };
|
|
40
|
+
try {
|
|
41
|
+
const { chromeBin } = await import('./creatives/siteshots.js');
|
|
42
|
+
const bin = chromeBin();
|
|
43
|
+
if (!bin)
|
|
44
|
+
throw new Error('no chromium');
|
|
45
|
+
const { execFile } = await import('node:child_process');
|
|
46
|
+
const { promisify } = await import('node:util');
|
|
47
|
+
const { stdout } = await promisify(execFile)(bin, ['--headless=new', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', '--window-size=1280,2000', '--virtual-time-budget=10000', `--user-agent=${UA_BROWSER}`, '--dump-dom', url], { timeout: 60_000, maxBuffer: 32 << 20 });
|
|
48
|
+
const html = String(stdout);
|
|
49
|
+
if (html.length > 1500 && !/access denied|attention required|cf-error|captcha/i.test(html.slice(0, 4000)))
|
|
50
|
+
return { html, status: 200, finalUrl: first?.finalUrl || url, headers: first?.headers || new Headers(), via: 'chromium' };
|
|
51
|
+
}
|
|
52
|
+
catch { /* 폴백 실패 → 원래 결과 */ }
|
|
53
|
+
if (first)
|
|
54
|
+
return { ...first, via: 'fetch' };
|
|
55
|
+
throw new Error('사이트에 연결하지 못했어요');
|
|
56
|
+
}
|
|
57
|
+
const UA_BROWSER = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
|
|
28
58
|
export async function readSite(url) {
|
|
29
|
-
const { html, status, finalUrl, headers } = await
|
|
59
|
+
const { html, status, finalUrl, headers } = await fetchHtmlSmart(url);
|
|
30
60
|
const title = decode((html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || meta(html, 'og:title') || '').trim());
|
|
31
61
|
const description = meta(html, 'description') || meta(html, 'og:description') || '';
|
|
32
62
|
const lang = (html.match(/<html[^>]*\slang\s*=\s*["']?([a-zA-Z-]+)/i)?.[1] || meta(html, 'og:locale') || '').toLowerCase().slice(0, 2);
|
package/package.json
CHANGED