adyou 0.5.0 → 0.5.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.js +6 -4
- package/dist/core/creatives/render.d.ts +2 -1
- package/dist/core/creatives/render.js +3 -1
- package/dist/core/creatives/storyboard.js +2 -2
- package/dist/core/creatives/videofx.d.ts +1 -1
- package/dist/core/creatives/videofx.js +17 -6
- package/package.json +1 -1
|
@@ -25,11 +25,12 @@ 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, person = false) {
|
|
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
|
+
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: person ? 'lower' : 'top' }), start: 0, end: hookEnd });
|
|
33
34
|
const lines = text.captions.filter(Boolean).slice(0, 3);
|
|
34
35
|
if (lines.length) {
|
|
35
36
|
const from = text.hook ? hookEnd - 0.2 : 0;
|
|
@@ -95,7 +96,8 @@ export async function produceConceptVideos(o) {
|
|
|
95
96
|
}
|
|
96
97
|
else {
|
|
97
98
|
const person = format === 'ugc_selfie';
|
|
98
|
-
|
|
99
|
+
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
100
|
+
const speech = clip.speech ? ` The person speaks to camera in ${langName(board.lang)}: "${clip.speech}". Natural lip sync, conversational, genuine. 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
101
|
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
102
|
const firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
|
|
101
103
|
const 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 });
|
|
@@ -153,7 +155,7 @@ export async function produceConceptVideos(o) {
|
|
|
153
155
|
}
|
|
154
156
|
if (job.captions) {
|
|
155
157
|
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) });
|
|
158
|
+
await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang, format === 'ugc_selfie' || format === 'lifestyle') });
|
|
157
159
|
cur = withCap;
|
|
158
160
|
}
|
|
159
161
|
if (job.endCard) {
|
|
@@ -31,7 +31,8 @@ export declare function renderLogo(dir: string, brief: Brief): Promise<CreativeA
|
|
|
31
31
|
export declare function renderOverlay(file: string, w: number, hh: number, text: string, o: {
|
|
32
32
|
kind: 'line' | 'cta' | 'hook';
|
|
33
33
|
primary: string;
|
|
34
|
-
lang?: string;
|
|
34
|
+
lang?: string; /** 훅 위치 — top(기본) · lower(사람 얼굴이 나오는 포맷: 가슴 아래 · 자막 자리) */
|
|
35
|
+
layout?: 'top' | 'lower';
|
|
35
36
|
}): Promise<string>;
|
|
36
37
|
/** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
|
|
37
38
|
export declare function renderPhoneStage(dir: string, w: number, hh: number, primary: string, dark: string): Promise<{
|
|
@@ -208,7 +208,9 @@ export async function renderOverlay(file, w, hh, text, o) {
|
|
|
208
208
|
const hook = o.kind === 'hook';
|
|
209
209
|
const hookEl = h('div', { display: 'flex', fontSize: Math.round(base * 0.095), fontWeight: 800, color: '#fff', 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
210
|
const el = hook
|
|
211
|
-
?
|
|
211
|
+
? (o.layout === 'lower'
|
|
212
|
+
? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
|
|
213
|
+
: h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), fontFamily: FONT_FAMILY }, [hookEl]))
|
|
212
214
|
: 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
215
|
const svg = await satori(el, { width: w, height: hh, fonts });
|
|
214
216
|
fs.writeFileSync(file, new Resvg(svg, { fitTo: { mode: 'width', value: w } }).render().asPng());
|
|
@@ -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}]}]}`,
|
|
@@ -119,7 +119,7 @@ export async function translateCopy(brief, concept, boards, langs) {
|
|
|
119
119
|
}
|
|
120
120
|
const r = await completeJson({
|
|
121
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단어
|
|
122
|
+
system: `너는 광고 현지화 카피라이터다. 아래 문구·자막·훅·대사를 각 언어로 옮긴다. 직역이 아니라 그 시장 광고에서 자연스러운 표현으로, 뜻·사실·숫자는 그대로. 헤드라인 ≤ ${GOOGLE_LIMITS.headline}자(한글·전각 2자) · 설명 ≤ ${GOOGLE_LIMITS.description}자 · 자막 각 18자(영문 32자) 이내 · 훅 6단어 이내 · 대사(voice)는 말하면 4~5초 안에 끝나는 짧은 한 문장(영어 ≤ 12단어 · 일본어 ≤ 22자). 브랜드명(${brief.company})은 번역하지 않는다. 출력 JSON {"translations":{"<lang>":{headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice}]}}}`,
|
|
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 })) }),
|
|
124
124
|
});
|
|
125
125
|
for (const l of targets) {
|
|
@@ -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>;
|
|
@@ -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) */
|
package/package.json
CHANGED