adyou 0.4.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.
@@ -9,7 +9,35 @@ import { HOME } from '../state.js';
9
9
  import { SIZES } from './specs.js';
10
10
  const FONT_CDN = 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard/dist/public/static';
11
11
  const WEIGHTS = [['Pretendard-ExtraBold', 800], ['Pretendard-Bold', 700], ['Pretendard-Medium', 500]];
12
- export async function loadFonts() {
12
+ /** 언어별 글꼴 폴백 — Pretendard 는 한글·라틴만. 일본어=Pretendard JP(가나·한자) · 중국어=Noto Sans TC/SC · 태국어=Noto Sans Thai. 같은 family 이름으로 넣어 satori 가 빠진 글리프를 다음 글꼴에서 찾게 한다. */
13
+ // 🔴 satori 는 같은 family 이름 안에서는 글리프 폴백을 안 한다(2026-09-16 실측 · 한자 tofu) → 언어별 글꼴은 다른 family 이름으로 넣고 fontFamily 목록으로 폴백
14
+ const EXTRA_FONTS = {
15
+ ja: [['https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard-jp/dist/public/static/PretendardJP-ExtraBold.otf', 'PretendardJP-ExtraBold.otf', 800, 'PretendardJP'], ['https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard-jp/dist/public/static/PretendardJP-Bold.otf', 'PretendardJP-Bold.otf', 700, 'PretendardJP'], ['https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard-jp/dist/public/static/PretendardJP-Medium.otf', 'PretendardJP-Medium.otf', 500, 'PretendardJP']],
16
+ 'zh-TW': [['https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Bold.otf', 'NotoSansCJKtc-Bold.otf', 700, 'NotoCJKtc']],
17
+ zh: [['https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Bold.otf', 'NotoSansCJKsc-Bold.otf', 700, 'NotoCJKsc']],
18
+ th: [['https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io@main/fonts/NotoSansThai/full/ttf/NotoSansThai-Bold.ttf', 'NotoSansThai-Bold.ttf', 700, 'NotoThai']],
19
+ };
20
+ export const FONT_FAMILY = 'Pretendard, PretendardJP, NotoCJKtc, NotoCJKsc, NotoThai';
21
+ const fontCache = new Map();
22
+ async function fetchFont(dir, url, file) {
23
+ const p = path.join(dir, file);
24
+ if (!fs.existsSync(p)) {
25
+ try {
26
+ const res = await fetch(url, { signal: AbortSignal.timeout(90_000) });
27
+ if (!res.ok)
28
+ return null;
29
+ fs.writeFileSync(p, Buffer.from(await res.arrayBuffer()));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ return fs.readFileSync(p);
36
+ }
37
+ export async function loadFonts(lang) {
38
+ const key = lang ? (EXTRA_FONTS[lang] ? lang : EXTRA_FONTS[lang.split('-')[0]] ? lang.split('-')[0] : '') : '';
39
+ if (fontCache.has(key))
40
+ return fontCache.get(key);
13
41
  const dir = path.join(HOME, 'fonts');
14
42
  fs.mkdirSync(dir, { recursive: true });
15
43
  const out = [];
@@ -26,6 +54,12 @@ export async function loadFonts() {
26
54
  const buf = fs.readFileSync(p);
27
55
  out.push({ name: 'Pretendard', data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), weight: weight, style: 'normal' });
28
56
  }
57
+ for (const [url, file, weight, family] of EXTRA_FONTS[key] || []) {
58
+ const buf = await fetchFont(dir, url, file);
59
+ if (buf)
60
+ out.push({ name: family, data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), weight: weight, style: 'normal' });
61
+ }
62
+ fontCache.set(key, out);
29
63
  return out;
30
64
  }
31
65
  /** 🔴 확장자가 아니라 바이트로 MIME 판별 — Gemini 이미지 API 는 .png 요청에도 JPEG 를 돌려준다(2026-09-16 실측 · 잘못된 MIME 이면 satori 가 배경을 조용히 버림) */
@@ -114,7 +148,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
114
148
  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)]),
115
149
  ]));
116
150
  }
117
- return h('div', { width: w, height: hh, display: 'flex', position: 'relative', overflow: 'hidden', fontFamily: 'Pretendard', backgroundColor: dark ? brief.palette.dark : brief.palette.light, ...bgStyle }, children);
151
+ return h('div', { width: w, height: hh, display: 'flex', position: 'relative', overflow: 'hidden', fontFamily: FONT_FAMILY, backgroundColor: dark ? brief.palette.dark : brief.palette.light, ...bgStyle }, children);
118
152
  }
119
153
  export const CTA_LABEL = {
120
154
  ko: { LEARN_MORE: '자세히 보기', SIGN_UP: '지금 시작하기', SHOP_NOW: '지금 구매', CONTACT_US: '문의하기', DOWNLOAD: '내려받기', GET_OFFER: '혜택 받기', GET_QUOTE: '견적 받기', SUBSCRIBE: '구독하기', APPLY_NOW: '지금 신청', ORDER_NOW: '지금 주문', BUY_NOW: '지금 구매', BOOK_TRAVEL: '예약하기' },
@@ -124,7 +158,7 @@ export const CTA_LABEL = {
124
158
  export function ctaLabel(cta, lang) { return CTA_LABEL[lang]?.[cta] || CTA_LABEL.en[cta] || CTA_LABEL.en.LEARN_MORE; }
125
159
  /** 컨셉 하나를 12규격으로 렌더 → 파일 목록. 이미 있는 파일은 건너뜀(--force로 다시). */
126
160
  export async function renderConcept(dir, brief, concept, backgrounds, opts = {}) {
127
- const fonts = await loadFonts();
161
+ const fonts = await loadFonts(brief.language);
128
162
  const logo = await fetchLogo(brief.logoUrl, dir);
129
163
  const out = [];
130
164
  const ctaText = ctaLabel(concept.cta, brief.language);
@@ -152,7 +186,7 @@ export async function renderLogo(dir, brief) {
152
186
  for (const [w, hh] of [[512, 512], [1200, 300]]) {
153
187
  const file = path.join(dir, `_logo_${w}x${hh}.png`);
154
188
  if (!fs.existsSync(file)) {
155
- const el = h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: brief.palette.primary, fontFamily: 'Pretendard' }, [
189
+ const el = h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: brief.palette.primary, fontFamily: FONT_FAMILY }, [
156
190
  logo ? h('img', { height: Math.round(hh * 0.5) }, undefined, { src: logo }) : h('div', { fontSize: Math.round(Math.min(w, hh) * (w === hh ? 0.16 : 0.42)), fontWeight: 800, color: '#fff', letterSpacing: -1, whiteSpace: 'nowrap' }, brief.company.slice(0, 16)),
157
191
  ]);
158
192
  const svg = await satori(el, { width: w, height: hh, fonts });
@@ -164,14 +198,37 @@ export async function renderLogo(dir, brief) {
164
198
  }
165
199
  /** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
166
200
  export async function renderOverlay(file, w, hh, text, o) {
167
- const fonts = await loadFonts();
201
+ const fonts = await loadFonts(o.lang);
168
202
  const base = Math.min(w, hh);
169
203
  const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
170
204
  const pill = o.kind === 'cta'
171
205
  ? h('div', { display: 'flex', fontSize: fs1, fontWeight: 700, color: '#fff', 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)
172
206
  : 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);
173
- const el = h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * (o.kind === 'cta' ? 0.09 : 0.17)), fontFamily: 'Pretendard' }, [pill]);
207
+ // 안전영역: 릴스·쇼츠 UI 하단 ~20%·우측 ~10% 가린다 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
208
+ const hook = o.kind === 'hook';
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
+ const el = hook
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]))
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]);
174
215
  const svg = await satori(el, { width: w, height: hh, fonts });
175
216
  fs.writeFileSync(file, new Resvg(svg, { fitTo: { mode: 'width', value: w } }).render().asPng());
176
217
  return file;
177
218
  }
219
+ /** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
220
+ export async function renderPhoneStage(dir, w, hh, primary, dark) {
221
+ const fonts = await loadFonts();
222
+ const sw = Math.round(w * 0.62);
223
+ const sh = Math.round(sw * 2.05);
224
+ const sx = Math.round((w - sw) / 2);
225
+ const sy = Math.round((hh - sh) / 2);
226
+ const bgSvg = await satori(h('div', { width: w, height: hh, display: 'flex', backgroundImage: `linear-gradient(160deg, ${dark} 0%, ${hexA(primary, 0.75)} 100%)`, fontFamily: FONT_FAMILY }, ''), { width: w, height: hh, fonts });
227
+ const bg = path.join(dir, `stage_bg_${w}x${hh}.png`);
228
+ fs.writeFileSync(bg, new Resvg(bgSvg).render().asPng());
229
+ const bezel = Math.round(sw * 0.045);
230
+ const frameSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${hh}"><defs><mask id="m"><rect width="${w}" height="${hh}" fill="#fff"/><rect x="${sx}" y="${sy}" width="${sw}" height="${sh}" rx="${Math.round(sw * 0.11)}" fill="#000"/></mask></defs><rect x="${sx - bezel}" y="${sy - bezel}" width="${sw + bezel * 2}" height="${sh + bezel * 2}" rx="${Math.round(sw * 0.14)}" fill="#0b0f18" mask="url(#m)"/><rect x="${sx - bezel}" y="${sy - bezel}" width="${sw + bezel * 2}" height="${sh + bezel * 2}" rx="${Math.round(sw * 0.14)}" fill="none" stroke="rgba(255,255,255,.18)" stroke-width="3"/><rect x="${sx + sw * 0.32}" y="${sy + bezel * 0.35}" width="${sw * 0.36}" height="${bezel * 0.9}" rx="${bezel * 0.45}" fill="#0b0f18"/></svg>`;
231
+ const frame = path.join(dir, `stage_frame_${w}x${hh}.png`);
232
+ fs.writeFileSync(frame, new Resvg(frameSvg).render().asPng());
233
+ return { bg, frame, screen: { x: sx, y: sy, w: sw, h: sh } };
234
+ }
@@ -0,0 +1,9 @@
1
+ export declare function chromeBin(): string | null;
2
+ /** 모바일 폭(390) 긴 스크린샷 PNG. 실패하면 null(호출자가 이미지 폴백). */
3
+ export declare function screenshotSite(url: string, file: string, o?: {
4
+ width?: number;
5
+ height?: number;
6
+ timeoutMs?: number;
7
+ }): Promise<string | null>;
8
+ /** 사이트 이미지 URL 들을 내려받아 유효한(≥300px 추정 · png/jpg/webp) 파일만 반환 */
9
+ export declare function downloadSiteImages(urls: string[], dir: string, max?: number): Promise<string[]>;
@@ -0,0 +1,53 @@
1
+ // 사이트 실제 화면·이미지 확보 — ui_demo 포맷과 참고 이미지(제품 일관성)에 쓴다.
2
+ // 1) 헤드리스 크로미움 스크린샷(모바일 폭 · 긴 페이지) — 바이너리: CHROME_BIN > alpine chromium-browser/chromium > macOS Chrome. 없으면 건너뜀.
3
+ // 2) 사이트 본문 이미지(og:image·큰 <img>) 내려받기 — 생성 이미지의 참고(/edits)로.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { sniffMime } from './render.js';
9
+ const execFileP = promisify(execFile);
10
+ export function chromeBin() {
11
+ const cands = [process.env.CHROME_BIN, '/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium'].filter(Boolean);
12
+ return cands.find((c) => fs.existsSync(c)) || null;
13
+ }
14
+ /** 모바일 폭(390) 긴 스크린샷 PNG. 실패하면 null(호출자가 이미지 폴백). */
15
+ export async function screenshotSite(url, file, o = {}) {
16
+ const bin = chromeBin();
17
+ if (!bin)
18
+ return null;
19
+ const w = o.width || 390, h = o.height || 2200;
20
+ try {
21
+ await execFileP(bin, ['--headless=new', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', '--hide-scrollbars', '--force-device-scale-factor=2', `--window-size=${w},${h}`, '--virtual-time-budget=8000', `--screenshot=${file}`, url], { timeout: o.timeoutMs || 45_000, maxBuffer: 8 << 20 });
22
+ if (!fs.existsSync(file) || fs.statSync(file).size < 5000)
23
+ return null;
24
+ return file;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** 사이트 이미지 URL 들을 내려받아 유효한(≥300px 추정 · png/jpg/webp) 파일만 반환 */
31
+ export async function downloadSiteImages(urls, dir, max = 4) {
32
+ const out = [];
33
+ for (const u of urls) {
34
+ if (out.length >= max)
35
+ break;
36
+ try {
37
+ const r = await fetch(u, { signal: AbortSignal.timeout(15_000), headers: { 'user-agent': 'Mozilla/5.0 (compatible; ADYou/0.4)' } });
38
+ if (!r.ok)
39
+ continue;
40
+ const buf = Buffer.from(await r.arrayBuffer());
41
+ if (buf.length < 8_000 || buf.length > 15_000_000)
42
+ continue;
43
+ const mime = sniffMime(buf);
44
+ if (!/png|jpeg|webp/.test(mime))
45
+ continue;
46
+ const f = path.join(dir, `site_${out.length}.${mime.split('/')[1] === 'jpeg' ? 'jpg' : mime.split('/')[1]}`);
47
+ fs.writeFileSync(f, buf);
48
+ out.push(f);
49
+ }
50
+ catch { /* 다음 */ }
51
+ }
52
+ return out;
53
+ }
@@ -0,0 +1,70 @@
1
+ import type { Brief, Concept, Goal } from '../state.js';
2
+ import { type Tone, type VideoFormat } from './playbook.js';
3
+ export type Clip = {
4
+ sec: number;
5
+ kind: 'gen' | 'ui_demo';
6
+ prompt: string;
7
+ speech?: string;
8
+ };
9
+ export type Storyboard = {
10
+ id: string;
11
+ format: VideoFormat;
12
+ genre: string;
13
+ hook: string;
14
+ captions: string[];
15
+ voice?: string;
16
+ music: string;
17
+ cta: string;
18
+ durationSec: number;
19
+ clips: Clip[];
20
+ score?: number;
21
+ scoreNote?: string;
22
+ lang: string;
23
+ };
24
+ export type LocalizedBoard = {
25
+ hook: string;
26
+ captions: string[];
27
+ voice?: string;
28
+ };
29
+ export type LocalizedCopy = {
30
+ headlines: string[];
31
+ bodies: string[];
32
+ descriptions: string[];
33
+ boards?: Record<string, LocalizedBoard>;
34
+ };
35
+ export type I18n = Record<string, LocalizedCopy>;
36
+ export declare const langName: (l: string) => string;
37
+ /** 훅 길이 제한 — 한글·전각 12자(wlen 24) · 라틴은 단어 경계에서 자른다(잘린 단어 금지) */
38
+ export declare function fitHook(t: string): string;
39
+ /** 포맷별 클립 구성 규칙 — 8초: UGC 1×8(발화) · 시네마틱/라이프스타일 2×4(훅→보상) · ui_demo 4(훅 gen)+4(화면) · 제품 1×8 · text_hook 2×4. 15/30초는 1클립 다중 장면 */
40
+ export declare function clipPlan(format: VideoFormat, durationSec: number, hasScreens: boolean): {
41
+ sec: number;
42
+ kind: 'gen' | 'ui_demo';
43
+ }[];
44
+ export declare function generateStoryboards(brief: Brief, concept: Concept, o: {
45
+ formats: VideoFormat[];
46
+ durationSec: number;
47
+ tone: Tone;
48
+ goal: Goal;
49
+ hasScreens: boolean;
50
+ lang?: string;
51
+ }): Promise<Storyboard[]>;
52
+ /** 채점 — 훅 강도·3초 명료성·정책 위험·브랜드 적합·타겟 적합(각 0~10) → 평균. 정책 위험은 규칙 검사(policyIssues)로 먼저 깎는다. */
53
+ export declare function judgeStoryboards(brief: Brief, boards: Storyboard[]): Promise<Storyboard[]>;
54
+ /** 콘티 생성 + 채점 + 약한 포맷 1회 재작성 → 점수순 */
55
+ export declare function planStoryboards(brief: Brief, concept: Concept, o: {
56
+ formats: VideoFormat[];
57
+ durationSec: number;
58
+ tone: Tone;
59
+ goal: Goal;
60
+ hasScreens: boolean;
61
+ lang?: string;
62
+ log?: (s: string) => void;
63
+ minScore?: number;
64
+ }): Promise<Storyboard[]>;
65
+ /** 시장 언어별 문구·자막·훅·대사 번역 — 뜻·사실 유지 · 규격 맞춤 */
66
+ export declare function translateCopy(brief: Brief, concept: Concept, boards: Storyboard[], langs: string[]): Promise<I18n>;
67
+ /** 언어별 콘티 자막/훅 꺼내기(없으면 원문) */
68
+ export declare function localizedBoard(b: Storyboard, i18n: I18n | undefined, lang: string): LocalizedBoard;
69
+ /** 언어별 컨셉(문구 치환) — 이미지 렌더·매체 카피에 */
70
+ export declare function localizedConcept(c: Concept, i18n: I18n | undefined, lang: string): Concept;
@@ -0,0 +1,149 @@
1
+ // 콘티(스토리보드) — 컨셉 × 포맷마다 「훅(0~2초) → 증명/보상 → 행동」 3박자 대본을 LLM 이 쓰고, 채점(훅 강도·명료성·정책·브랜드 적합)으로 약한 것을 걸러낸다.
2
+ // 영상 크레딧을 쓰기 전 텍스트 단계에서 품질을 올리는 것이 목적(비용 거의 0). 시장 언어별 번역(i18n)도 여기서.
3
+ import { z } from 'zod';
4
+ import { aiAvailable, completeJson } from '../llm.js';
5
+ import { ctaLabel } from './render.js';
6
+ import { FORMAT_DESC, genreStyle, playbookFor, TONE_WORDS } from './playbook.js';
7
+ import { fit, GOOGLE_LIMITS, policyIssues, wlen } from './specs.js';
8
+ const BoardSchema = z.object({
9
+ format: z.string(), genre: z.string().optional(), hook: z.string(), captions: z.array(z.string()).min(1).max(5), voice: z.string().optional().nullable(), music: z.string().optional(),
10
+ clips: z.array(z.object({ sec: z.number(), kind: z.enum(['gen', 'ui_demo']).default('gen'), prompt: z.string(), speech: z.string().optional().nullable() })).min(1).max(3),
11
+ });
12
+ const LANG_NAME = { ko: 'Korean', en: 'English', ja: 'Japanese', 'zh-TW': 'Traditional Chinese', zh: 'Chinese', de: 'German', fr: 'French', es: 'Spanish', vi: 'Vietnamese', th: 'Thai', id: 'Indonesian', pt: 'Portuguese', it: 'Italian', nl: 'Dutch' };
13
+ export const langName = (l) => LANG_NAME[l] || LANG_NAME[l.split('-')[0]] || l;
14
+ /** 훅 길이 제한 — 한글·전각 12자(wlen 24) · 라틴은 단어 경계에서 자른다(잘린 단어 금지) */
15
+ export function fitHook(t) {
16
+ const s = t.trim().replace(/\s+/g, ' ');
17
+ if (wlen(s) <= 26)
18
+ return s;
19
+ if (/[\u3000-\u9fff\uac00-\ud7af]/.test(s))
20
+ return fit(s, 26);
21
+ const words = s.split(' ');
22
+ let out = '';
23
+ for (const w of words) {
24
+ if ((out + ' ' + w).trim().length > 34)
25
+ break;
26
+ out = (out + ' ' + w).trim();
27
+ }
28
+ return out || fit(s, 30);
29
+ }
30
+ /** 포맷별 클립 구성 규칙 — 8초: UGC 1×8(발화) · 시네마틱/라이프스타일 2×4(훅→보상) · ui_demo 4(훅 gen)+4(화면) · 제품 1×8 · text_hook 2×4. 15/30초는 1클립 다중 장면 */
31
+ export function clipPlan(format, durationSec, hasScreens) {
32
+ if (durationSec > 8)
33
+ return [{ sec: durationSec, kind: 'gen' }];
34
+ if (format === 'ugc_selfie' || format === 'product_hero')
35
+ return [{ sec: 8, kind: 'gen' }];
36
+ if (format === 'ui_demo')
37
+ return hasScreens ? [{ sec: 4, kind: 'gen' }, { sec: 4, kind: 'ui_demo' }] : [{ sec: 8, kind: 'gen' }];
38
+ return [{ sec: 4, kind: 'gen' }, { sec: 4, kind: 'gen' }];
39
+ }
40
+ export async function generateStoryboards(brief, concept, o) {
41
+ const lang = o.lang || brief.language;
42
+ const cta = ctaLabel(concept.cta, lang);
43
+ const pb = playbookFor(brief.industry);
44
+ const genres = brief.visualGenres?.length ? brief.visualGenres : pb.genres;
45
+ const tone = TONE_WORDS[o.tone];
46
+ const plans = o.formats.map((f) => ({ format: f, clips: clipPlan(f, o.durationSec, o.hasScreens) }));
47
+ if (!aiAvailable())
48
+ return plans.map((p) => fallbackBoard(brief, concept, p.format, p.clips, genres[0], cta, lang, o.durationSec));
49
+ const out = await completeJson({
50
+ schema: z.object({ boards: z.array(BoardSchema).min(1) }), maxTokens: 7000, timeoutMs: 150_000,
51
+ system: `너는 퍼포먼스 광고 크리에이티브 디렉터다. 주어진 광고 컨셉을 포맷별 짧은 영상 콘티로 만든다. 목표: 첫 1~2초 안에 손가락을 멈추게 하고(훅), 3초 안에 무엇인지 보이고, 마지막에 행동을 시킨다.
52
+ 업종 플레이북(${pb.label}): 포맷 우선 ${pb.formats.join('·')} · 훅 패턴 ${pb.hooks.join(' / ')} · 꼭 보일 것 ${pb.musts.join('·')} · 피할 것 ${pb.avoid.join('·')}.
53
+ 비주얼 장르(우선순위): ${genres.join(' > ')} — ${genres.map((g) => `${g}=${genreStyle(g)}`).join(' | ')}. 애니(anime)는 브랜드가 애니·게임·웹툰일 때만 쓴다.
54
+ 톤: ${o.tone} → 카메라 「${tone.camera}」 · 음악 「${tone.music}」 · 자막 ${tone.caption}.
55
+ 포맷 정의: ${o.formats.map((f) => `${f}=${FORMAT_DESC[f]}`).join(' | ')}.
56
+ 클립 구성(포맷별 고정 · 초 수를 지켜라): ${plans.map((p) => `${p.format}: ${p.clips.map((c) => `${c.sec}s ${c.kind}`).join(' + ')}`).join(' / ')}.
57
+ 규칙:
58
+ - boards 는 요청 포맷마다 정확히 1개. 각 board: format · genre(위 장르 중 하나) · hook(첫 프레임 큰 글자 · ${lang} · 6단어/12자 이내 · 질문/숫자/반전) · captions(화면 자막 3줄 · ${lang} · 각 18자 이내 · 훅→가치/증명→행동 순 · 마지막 줄은 행동 유도) · voice(선택 · ${lang} 한 문장 내레이션/대사) · music(영어 한 줄).
59
+ - clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
60
+ - ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 한 문장(영어 ≤ 12단어 · 한국어/일본어 ≤ 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다(뚝 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
61
+ - ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
62
+ - 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
63
+ - 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
64
+ user: JSON.stringify({ brief: { company: brief.company, offer: brief.offer, category: brief.category, industry: brief.industry, audience: brief.audience, audienceProfile: brief.audienceProfile, usp: brief.usp, proofPoints: brief.proofPoints, tone: brief.tone, language: lang, palette: brief.palette }, concept: { key: concept.key, name: concept.name, angle: concept.angle, headline: concept.headlines[0], body: concept.bodies[0], imagePrompt: concept.imagePrompt }, goal: o.goal, cta, durationSec: o.durationSec, formats: o.formats }),
65
+ });
66
+ const boards = [];
67
+ for (const p of plans) {
68
+ const b = out.boards.find((x) => x.format === p.format) || out.boards[boards.length];
69
+ if (!b) {
70
+ boards.push(fallbackBoard(brief, concept, p.format, p.clips, genres[0], cta, lang, o.durationSec));
71
+ continue;
72
+ }
73
+ 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 }; });
74
+ 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: b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3), voice: b.voice || undefined, music: b.music || tone.music, cta, durationSec: o.durationSec, clips, lang });
75
+ }
76
+ return boards;
77
+ }
78
+ function fallbackBoard(brief, concept, format, plan, genre, cta, lang, durationSec) {
79
+ const style = genreStyle(genre);
80
+ const base = concept.videoPrompt || `A short advertisement scene inspired by: ${concept.imagePrompt}.`;
81
+ const clips = plan.map((p, i) => p.kind === 'ui_demo' ? { sec: p.sec, kind: 'ui_demo', prompt: concept.headlines[0] } : { sec: p.sec, kind: 'gen', prompt: `${i === 0 ? 'Opening hook shot with immediate motion: ' : 'Payoff shot: '}${base} ${style}. Upbeat modern music. No on-screen text, no subtitles, no logos.` });
82
+ return { id: `${concept.key}_${format}`, format, genre: format === 'ugc_selfie' ? 'ugc' : genre, hook: fitHook(concept.headlines[0] || brief.company), captions: (concept.videoLines?.length ? concept.videoLines : [concept.headlines[0], concept.bodies[0]?.slice(0, 18) || '', cta]).filter(Boolean).slice(0, 3), music: 'upbeat modern music', cta, durationSec, clips, lang };
83
+ }
84
+ /** 채점 — 훅 강도·3초 명료성·정책 위험·브랜드 적합·타겟 적합(각 0~10) → 평균. 정책 위험은 규칙 검사(policyIssues)로 먼저 깎는다. */
85
+ export async function judgeStoryboards(brief, boards) {
86
+ const ruleHit = (b) => policyIssues([b.hook, ...b.captions, b.voice || ''].join(' ')).length;
87
+ if (!aiAvailable())
88
+ return boards.map((b) => ({ ...b, score: Math.max(0, 7 - ruleHit(b) * 3), scoreNote: ruleHit(b) ? '정책 표현 감지' : '규칙 채점' }));
89
+ const out = await completeJson({
90
+ schema: z.object({ scores: z.array(z.object({ id: z.string(), hook: z.number(), clarity: z.number(), policy: z.number(), fit: z.number(), target: z.number(), note: z.string().optional() })) }), maxTokens: 2500, timeoutMs: 90_000,
91
+ system: `너는 퍼포먼스 광고 심사관이다. 각 콘티를 0~10 으로 채점한다: hook(첫 1~2초에 멈추게 하나 · 질문/숫자/반전/움직임) · clarity(3초 안에 무엇을 파는지 보이나) · policy(10=안전 · 과장·보장·전후비교·개인속성 지칭·성인 암시가 있으면 크게 깎음) · fit(브랜드 톤·업종·장르 적합 · 애니 브랜드가 아닌데 애니면 깎음) · target(타겟(${JSON.stringify(brief.audienceProfile || brief.audience)})에게 말이 되나). note 는 20자 이내 한 줄. 출력 JSON {"scores":[{id,hook,clarity,policy,fit,target,note}]}`,
92
+ user: JSON.stringify({ brief: { company: brief.company, offer: brief.offer, industry: brief.industry, usp: brief.usp, tone: brief.tone }, boards: boards.map((b) => ({ id: b.id, format: b.format, genre: b.genre, hook: b.hook, captions: b.captions, voice: b.voice, clips: b.clips.map((c) => c.prompt.slice(0, 300)) })) }),
93
+ });
94
+ return boards.map((b) => { const s = out.scores.find((x) => x.id === b.id); const avg = s ? (s.hook + s.clarity + s.policy + s.fit + s.target) / 5 : 6; const pen = ruleHit(b) * 2; return { ...b, score: Math.round(Math.max(0, avg - pen) * 10) / 10, scoreNote: `${s?.note || ''}${pen ? ' · 정책 표현' : ''}`.trim() }; });
95
+ }
96
+ /** 콘티 생성 + 채점 + 약한 포맷 1회 재작성 → 점수순 */
97
+ export async function planStoryboards(brief, concept, o) {
98
+ const min = o.minScore ?? 6.5;
99
+ let boards = await judgeStoryboards(brief, await generateStoryboards(brief, concept, o));
100
+ const weak = boards.filter((b) => (b.score ?? 0) < min);
101
+ if (weak.length && aiAvailable()) {
102
+ o.log?.(` 콘티 채점: ${boards.map((b) => `${b.format} ${b.score}`).join(' · ')} → 약한 ${weak.length}개 다시 씀`);
103
+ const redo = await judgeStoryboards(brief, await generateStoryboards(brief, concept, { ...o, formats: weak.map((w) => w.format) }));
104
+ boards = boards.map((b) => { const r = redo.find((x) => x.format === b.format); return r && (r.score ?? 0) > (b.score ?? 0) ? r : b; });
105
+ }
106
+ o.log?.(` 콘티: ${boards.map((b) => `${b.format}(${b.genre}) ${b.score}점 「${b.hook}」`).join(' · ')}`);
107
+ return boards.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
108
+ }
109
+ /** 시장 언어별 문구·자막·훅·대사 번역 — 뜻·사실 유지 · 규격 맞춤 */
110
+ export async function translateCopy(brief, concept, boards, langs) {
111
+ const targets = [...new Set(langs.map((l) => l).filter((l) => l.split('-')[0] !== (brief.language || 'ko').split('-')[0]))];
112
+ const out = {};
113
+ if (!targets.length)
114
+ return out;
115
+ if (!aiAvailable()) {
116
+ for (const l of targets)
117
+ out[l] = { headlines: concept.headlines, bodies: concept.bodies, descriptions: concept.descriptions };
118
+ return out;
119
+ }
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단어 이내 · 대사(voice)는 말하면 4~5초 안에 끝나는 짧은 한 문장(영어 ≤ 12단어 · 일본어 ≤ 22자). 브랜드명(${brief.company})은 번역하지 않는다. 출력 JSON {"translations":{"<lang>":{headlines[],bodies[],descriptions[],boards:[{id,hook,captions[],voice}]}}}`,
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
+ });
125
+ for (const l of targets) {
126
+ const t = r.translations[l] || r.translations[l.split('-')[0]];
127
+ if (!t)
128
+ continue;
129
+ out[l] = { headlines: t.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean), bodies: t.bodies.map((b) => b.trim()).filter(Boolean), descriptions: t.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean) };
130
+ for (const b of boards) {
131
+ const tb = t.boards?.find((x) => x.id === b.id);
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 } };
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+ /** 언어별 콘티 자막/훅 꺼내기(없으면 원문) */
139
+ export function localizedBoard(b, i18n, lang) {
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 };
142
+ }
143
+ /** 언어별 컨셉(문구 치환) — 이미지 렌더·매체 카피에 */
144
+ export function localizedConcept(c, i18n, lang) {
145
+ const t = i18n?.[lang];
146
+ if (!t)
147
+ return c;
148
+ return { ...c, headlines: t.headlines.length ? t.headlines : c.headlines, bodies: t.bodies.length ? t.bodies : c.bodies, descriptions: t.descriptions.length ? t.descriptions : c.descriptions };
149
+ }
@@ -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>;
@@ -26,3 +26,34 @@ export declare function deriveRatio(input: string, output: string, ratio: VideoR
26
26
  export declare function normalize(input: string, output: string, ratio: VideoRatio, maxSec?: number): Promise<Probe>;
27
27
  /** 대표 프레임(썸네일) — 1초 지점 JPEG */
28
28
  export declare function thumbnail(input: string, output: string, atSec?: number): Promise<void>;
29
+ /** 클립 이어붙이기 — 코덱·fps·오디오 정규화 뒤 concat(무음 클립은 무음 트랙 추가) · 컷 사이 0.25초 크로스페이드 없음(빠른 컷이 광고 표준) */
30
+ export declare function concatClips(inputs: string[], output: string, dims: {
31
+ w: number;
32
+ h: number;
33
+ }): Promise<void>;
34
+ /** UI 데모 클립 — 긴 스크린샷을 폰 프레임 안에서 아래로 스크롤(sec 초) · 배경 그래디언트 · 무음(뒤에서 음악 클립과 이어짐) */
35
+ export declare function uiDemoClip(shot: string, output: string, o: {
36
+ sec: number;
37
+ stage: {
38
+ bg: string;
39
+ frame: string;
40
+ screen: {
41
+ x: number;
42
+ y: number;
43
+ w: number;
44
+ h: number;
45
+ };
46
+ };
47
+ dims: {
48
+ w: number;
49
+ h: number;
50
+ };
51
+ }): Promise<void>;
52
+ /** 정지 이미지 → 켄번즈(줌인) 클립 · 무음 */
53
+ export declare function kenBurnsClip(image: string, output: string, o: {
54
+ sec: number;
55
+ dims: {
56
+ w: number;
57
+ h: number;
58
+ };
59
+ }): 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) */
@@ -84,3 +95,42 @@ export async function normalize(input, output, ratio, maxSec = 60) {
84
95
  export async function thumbnail(input, output, atSec = 1) {
85
96
  await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', String(atSec), '-i', input, '-frames:v', '1', '-q:v', '3', output]);
86
97
  }
98
+ /** 클립 이어붙이기 — 코덱·fps·오디오 정규화 뒤 concat(무음 클립은 무음 트랙 추가) · 컷 사이 0.25초 크로스페이드 없음(빠른 컷이 광고 표준) */
99
+ export async function concatClips(inputs, output, dims) {
100
+ if (inputs.length === 1) {
101
+ fs.copyFileSync(inputs[0], output);
102
+ return;
103
+ }
104
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-cat-'));
105
+ const norm = [];
106
+ for (const [i, f] of inputs.entries()) {
107
+ const p = await probe(f);
108
+ const out = path.join(dir, `n${i}.mp4`);
109
+ const base = ['-y', '-v', 'error', '-i', f, ...(p.hasAudio ? [] : ['-f', 'lavfi', '-t', String(Math.max(0.5, p.durationSec)), '-i', 'anullsrc=r=48000:cl=stereo']), '-vf', `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h},fps=24,format=yuv420p`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', ...(p.hasAudio ? [] : ['-shortest']), out];
110
+ await execFileP('ffmpeg', base, { maxBuffer: 8 << 20 });
111
+ norm.push(out);
112
+ }
113
+ const fc = norm.map((_, i) => `[${i}:v][${i}:a]`).join('') + `concat=n=${norm.length}:v=1:a=1[v][a]`;
114
+ await execFileP('ffmpeg', ['-y', '-v', 'error', ...norm.flatMap((f) => ['-i', f]), '-filter_complex', fc, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
115
+ fs.rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ /** UI 데모 클립 — 긴 스크린샷을 폰 프레임 안에서 아래로 스크롤(sec 초) · 배경 그래디언트 · 무음(뒤에서 음악 클립과 이어짐) */
118
+ export async function uiDemoClip(shot, output, o) {
119
+ const sc = o.stage.screen;
120
+ const fps = 24;
121
+ const frames = Math.round(o.sec * fps);
122
+ // 스크린샷을 화면 폭에 맞춰 스케일 → 시간에 따라 crop y 이동(끝에서 멈춤) → 배경 위 오버레이 → 프레임 오버레이
123
+ const fc = [
124
+ `[1:v]scale=${sc.w}:-2[ss]`,
125
+ `[ss]crop=${sc.w}:${sc.h}:0:'min(ih-${sc.h}, max(0, (ih-${sc.h})*min(1, t/${Math.max(1, o.sec - 1)})))'[scr]`,
126
+ `[0:v][scr]overlay=${sc.x}:${sc.y}[st]`,
127
+ `[st][2:v]overlay=0:0,format=yuv420p[v]`,
128
+ ].join(';');
129
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-framerate', String(fps), '-t', String(o.sec), '-i', o.stage.bg, '-loop', '1', '-framerate', String(fps), '-t', String(o.sec), '-i', shot, '-loop', '1', '-framerate', String(fps), '-t', String(o.sec), '-i', o.stage.frame, '-f', 'lavfi', '-t', String(o.sec), '-i', 'anullsrc=r=48000:cl=stereo', '-filter_complex', fc, '-map', '[v]', '-map', '3:a', '-frames:v', String(frames), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
130
+ }
131
+ /** 정지 이미지 → 켄번즈(줌인) 클립 · 무음 */
132
+ export async function kenBurnsClip(image, output, o) {
133
+ const fps = 24;
134
+ const frames = Math.round(o.sec * fps);
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 });
136
+ }
package/dist/core/ops.js CHANGED
@@ -7,6 +7,7 @@ import { generateConcepts, validateConcepts } from './creatives/copy.js';
7
7
  import { generateBackgrounds, userBackgrounds } from './creatives/images.js';
8
8
  import { chargeFor, resolveCreativePlan } from './creatives/media.js';
9
9
  import { produceConceptVideos } from './creatives/factory.js';
10
+ import { downloadSiteImages, screenshotSite } from './creatives/siteshots.js';
10
11
  import { renderConcept, renderLogo } from './creatives/render.js';
11
12
  import { SIZES } from './creatives/specs.js';
12
13
  import { aiAvailable, completeJson } from './llm.js';
@@ -60,7 +61,7 @@ export async function opCreatives(p, o) {
60
61
  spec.video = { ...(spec.video || {}), count: 1 };
61
62
  if (o.videoSeconds)
62
63
  spec.video = { ...(spec.video || {}), durationSec: (o.videoSeconds <= 8 ? 8 : o.videoSeconds <= 15 ? 15 : 30) };
63
- const cplan = resolveCreativePlan(spec, { monthlyKrw: p.budget.monthlyKrw, media: connectedMedia() });
64
+ const cplan = resolveCreativePlan(spec, { monthlyKrw: p.budget.monthlyKrw, media: connectedMedia(), industry: p.brief.industry, genres: p.brief.visualGenres, hasScreens: true });
64
65
  p.creative = spec;
65
66
  const count = o.count || cplan.concepts;
66
67
  const dir = creativesDir(p.slug);
@@ -85,6 +86,15 @@ export async function opCreatives(p, o) {
85
86
  assets.push(...(await renderLogo(dir, p.brief)));
86
87
  let costKrw = 0;
87
88
  const refs = o.images ? fs.readdirSync(o.images).filter((f) => /\.(png|jpe?g|webp)$/i.test(f)).map((f) => path.join(o.images, f)) : [];
89
+ // 사이트 실제 화면·이미지 — ui_demo 포맷과 참고 이미지(제품 일관성)
90
+ let siteShot = null;
91
+ let siteImages = [];
92
+ if (cplan.videos.perConcept.length) {
93
+ siteShot = await screenshotSite(p.url, path.join(dir, 'site_shot.png'));
94
+ siteImages = await downloadSiteImages(p.brief.siteImages || [], dir, 3);
95
+ log(`사이트 화면 ${siteShot ? '캡처 ✓' : '캡처 불가(크로미움 없음)'} · 사이트 이미지 ${siteImages.length}장`);
96
+ }
97
+ const langs = [...new Set(p.budget.markets.map((m) => m.language))];
88
98
  p.concepts.forEach((c, i) => { void i; });
89
99
  for (const [ci, c] of p.concepts.entries()) {
90
100
  let bgs;
@@ -94,12 +104,12 @@ export async function opCreatives(p, o) {
94
104
  bgs = {};
95
105
  else {
96
106
  log(`배경 이미지 생성 ${c.key}…`);
97
- bgs = await generateBackgrounds(dir, c.key, c.imagePrompt, undefined, log, { model: cplan.images.model, refs: spec.source === 'guided' ? refs : undefined, onAsset: (a) => { costKrw += a.costKrw; } });
107
+ bgs = await generateBackgrounds(dir, c.key, c.imagePrompt, undefined, log, { model: cplan.images.model, refs: spec.source === 'guided' ? refs : siteImages.slice(0, 2), onAsset: (a) => { costKrw += a.costKrw; } });
98
108
  }
99
109
  log(`합성 ${c.key} × ${SIZES.length}규격…`);
100
110
  assets.push(...(await renderConcept(dir, p.brief, c, bgs, { force: o.force || !!o.feedback, log })));
101
111
  if (cplan.videos.perConcept.length && ci < cplan.videos.conceptsWithVideo) {
102
- const vids = await produceConceptVideos({ dir, brief: p.brief, concept: c, jobs: cplan.videos.perConcept, backgrounds: bgs, refs, log, onAsset: (a) => { costKrw += a.costKrw; } });
112
+ const vids = await produceConceptVideos({ dir, brief: p.brief, concept: c, jobs: cplan.videos.perConcept, backgrounds: bgs, refs: refs.length ? refs : siteImages, siteShot, siteImages, langs, tone: cplan.tone, goal: p.budget.goal, log, onAsset: (a) => { costKrw += a.costKrw; } });
103
113
  assets.push(...vids.filter((v) => v.file));
104
114
  }
105
115
  }
@@ -9,6 +9,8 @@ export type SiteSnapshot = {
9
9
  logo: string | null;
10
10
  themeColor: string | null;
11
11
  colors: string[];
12
+ /** 사이트 본문의 큰 이미지(제품·화면·인물) — 소재 참고 이미지로 자동 사용 */
13
+ images: string[];
12
14
  headings: string[];
13
15
  prices: string[];
14
16
  text: string;