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.
@@ -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
- const speech = clip.speech ? ` The person speaks to camera in ${langName(board.lang)}: "${clip.speech}". Natural lip sync, conversational, genuine.` : '';
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
- ? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), fontFamily: FONT_FAMILY }, [hookEl])
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 는 8초에 맞는 한 문장(${lang}) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<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단어 이내. 브랜드명(${brief.company})은 번역하지 않는다. 출력 JSON {"translations":{"<lang>":{headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice}]}}}`,
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
- await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-t', String(seconds), '-i', cardPng, '-f', 'lavfi', '-t', String(seconds), '-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]);
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
- // 본편도 같은 코덱·프레임레이트·오디오로 정규화(무음이면 무음 트랙 추가) concat 필터
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(p.durationSec), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', main];
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
- 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 });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",