adyou 0.3.0 → 0.5.0
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/adapters/meta.d.ts +2 -0
- package/dist/adapters/meta.js +121 -44
- package/dist/adapters/mock.js +24 -10
- package/dist/adapters/types.d.ts +30 -0
- package/dist/adapters/types.js +16 -0
- package/dist/cli.js +2 -2
- package/dist/core/brief.js +12 -3
- package/dist/core/creatives/copy.d.ts +12 -3
- package/dist/core/creatives/copy.js +27 -6
- package/dist/core/creatives/factory.d.ts +29 -0
- package/dist/core/creatives/factory.js +183 -0
- package/dist/core/creatives/images.d.ts +31 -2
- package/dist/core/creatives/images.js +126 -57
- package/dist/core/creatives/media.d.ts +118 -0
- package/dist/core/creatives/media.js +91 -0
- package/dist/core/creatives/playbook.d.ts +40 -0
- package/dist/core/creatives/playbook.js +61 -0
- package/dist/core/creatives/render.d.ts +23 -2
- package/dist/core/creatives/render.js +80 -5
- package/dist/core/creatives/siteshots.d.ts +9 -0
- package/dist/core/creatives/siteshots.js +53 -0
- package/dist/core/creatives/storyboard.d.ts +70 -0
- package/dist/core/creatives/storyboard.js +149 -0
- package/dist/core/creatives/video.d.ts +35 -0
- package/dist/core/creatives/video.js +177 -0
- package/dist/core/creatives/videofx.d.ts +59 -0
- package/dist/core/creatives/videofx.js +125 -0
- package/dist/core/llm.js +9 -2
- package/dist/core/ops.d.ts +6 -0
- package/dist/core/ops.js +38 -6
- package/dist/core/site.d.ts +2 -0
- package/dist/core/site.js +5 -1
- package/dist/core/state.d.ts +43 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/mcp.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type VideoFormat = 'ugc_selfie' | 'ui_demo' | 'cinematic' | 'product_hero' | 'lifestyle' | 'text_hook';
|
|
2
|
+
export declare const FORMAT_LABEL: Record<VideoFormat, string>;
|
|
3
|
+
export declare const FORMAT_DESC: Record<VideoFormat, string>;
|
|
4
|
+
export type Tone = 'calm' | 'lively' | 'bold';
|
|
5
|
+
export declare const TONE_LABEL: Record<Tone, string>;
|
|
6
|
+
export declare const TONE_WORDS: Record<Tone, {
|
|
7
|
+
camera: string;
|
|
8
|
+
music: string;
|
|
9
|
+
caption: string;
|
|
10
|
+
}>;
|
|
11
|
+
export type Playbook = {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
/** 우선순위 포맷(앞이 먼저) */
|
|
15
|
+
formats: VideoFormat[];
|
|
16
|
+
/** 훅 패턴(콘티 LLM 에 예시로) */
|
|
17
|
+
hooks: string[];
|
|
18
|
+
/** 기본 비주얼 장르 */
|
|
19
|
+
genres: string[];
|
|
20
|
+
/** 꼭 보여줄 것 */
|
|
21
|
+
musts: string[];
|
|
22
|
+
/** 피할 것(정책·상식) */
|
|
23
|
+
avoid: string[];
|
|
24
|
+
/** 기본 톤 */
|
|
25
|
+
tone: Tone;
|
|
26
|
+
};
|
|
27
|
+
export declare const INDUSTRIES: Record<string, Playbook>;
|
|
28
|
+
export declare const INDUSTRY_KEYS: string[];
|
|
29
|
+
export declare function playbookFor(industry?: string | null): Playbook;
|
|
30
|
+
/** 이 브리프에 맞는 영상 포맷 n개 — 플레이북 순서를 따르되 장르(anime 등)·화면 유무를 반영 · 항상 서로 다른 포맷 */
|
|
31
|
+
export declare function pickFormats(o: {
|
|
32
|
+
industry?: string | null;
|
|
33
|
+
genres?: string[];
|
|
34
|
+
hasScreens: boolean;
|
|
35
|
+
count: number;
|
|
36
|
+
requested?: VideoFormat[];
|
|
37
|
+
}): VideoFormat[];
|
|
38
|
+
/** 장르 → 이미지/영상 프롬프트 스타일 토큰 */
|
|
39
|
+
export declare const GENRE_STYLE: Record<string, string>;
|
|
40
|
+
export declare const genreStyle: (g?: string) => string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export const FORMAT_LABEL = { ugc_selfie: '셀카 후기(UGC)', ui_demo: '화면 데모', cinematic: '시네마틱', product_hero: '제품 히어로', lifestyle: '라이프스타일', text_hook: '큰 글자 훅' };
|
|
2
|
+
export const FORMAT_DESC = {
|
|
3
|
+
ugc_selfie: '실제 고객처럼 보이는 인물이 셀카로 한마디 — 네이티브 톤, 소비재·앱에서 CPM 낮고 CTR 높음',
|
|
4
|
+
ui_demo: '실제 사이트/앱 화면이 폰 안에서 움직임 — 앱·SaaS·쇼핑몰에서 「무엇인지」를 3초 안에 보여줌(생성 비용 0)',
|
|
5
|
+
cinematic: '장르(실사·애니·3D)에 맞는 연출 컷 2개(훅 → 보상) — 브랜드 무드·세계관',
|
|
6
|
+
product_hero: '제품/결과물이 주인공인 클로즈업·회전·질감 — 뷰티·식품·패션',
|
|
7
|
+
lifestyle: '타겟이 제품을 쓰는 장면 — 여행·피트니스·가전',
|
|
8
|
+
text_hook: '큰 글자 질문/숫자로 시작해 증거로 이어가는 텍스트 중심 — B2B·교육·서비스',
|
|
9
|
+
};
|
|
10
|
+
export const TONE_LABEL = { calm: '차분하게', lively: '활기차게', bold: '강하게' };
|
|
11
|
+
export const TONE_WORDS = {
|
|
12
|
+
calm: { camera: 'smooth, steady camera, gentle motion, soft natural light', music: 'soft, warm, understated background music', caption: '차분한 서술형' },
|
|
13
|
+
lively: { camera: 'energetic handheld feel, quick push-ins, dynamic motion in the first second, bright lighting', music: 'upbeat, modern, rhythmic music with a clear beat', caption: '짧고 리듬 있는 구어체' },
|
|
14
|
+
bold: { camera: 'punchy fast cuts, dramatic close-ups, strong contrast, high energy from frame one', music: 'driving, bold, high-energy music with impact hits', caption: '짧고 강한 선언형' },
|
|
15
|
+
};
|
|
16
|
+
const P = (key, label, formats, hooks, genres, musts, avoid, tone = 'lively') => ({ key, label, formats, hooks, genres, musts, avoid, tone });
|
|
17
|
+
export const INDUSTRIES = {
|
|
18
|
+
app_game: P('app_game', '앱·게임·엔터·웹툰', ['ugc_selfie', 'ui_demo', 'cinematic'], ['「이거 해봤어?」 반응 셀카', '캐릭터/세계관 컷 → 실제 화면', '선택지 두 개를 보여주고 결말 예고'], ['anime', 'ugc', '3d'], ['실제 앱 화면 3초 안에', '재미·몰입의 순간'], ['현금·보상 과장', '성인 암시', '타사 IP·실존 인물'], 'lively'),
|
|
19
|
+
saas: P('saas', 'SaaS·업무 도구', ['ui_demo', 'text_hook', 'ugc_selfie'], ['「아직 이걸 손으로 하세요?」', '숫자 훅(「하루 3시간 절약」은 근거 있을 때만)', '전/후 업무 화면(건강 아님·허용)'], ['photoreal', 'motion_graphics', 'ugc'], ['핵심 화면·결과물', '누구를 위한 도구인지'], ['검증 없는 수치', '경쟁사 비방'], 'lively'),
|
|
20
|
+
ecommerce_beauty: P('ecommerce_beauty', '화장품·뷰티', ['ugc_selfie', 'product_hero', 'lifestyle'], ['텍스처·발림 클로즈업', '「이거 하나로」 루틴 셀카', '성분 하나 강조'], ['photoreal', 'product_shot', 'ugc'], ['제품 질감·사용 장면', '피부 톤 다양'], ['전후 비교 사진', '효능 단정(「완치」「100%」)', '신체 부위 클로즈업 과다'], 'lively'),
|
|
21
|
+
ecommerce_health: P('ecommerce_health', '건강기능식품·헬스 보조', ['ugc_selfie', 'product_hero', 'text_hook'], ['「아침에 이거 하나」 루틴', '성분·인증 마크(실제 있는 것만)', '「이런 분께」 타겟 지칭 없이 상황으로'], ['photoreal', 'product_shot', 'ugc'], ['제품·성분·인증(사이트 근거)', '복용 장면'], ['질병 치료·예방 표현', '전후 비교', '개인 건강상태 지칭(「당신은 비만」)', '의사 연출'], 'calm'),
|
|
22
|
+
ecommerce_fashion: P('ecommerce_fashion', '패션·잡화', ['lifestyle', 'product_hero', 'ugc_selfie'], ['핏·움직임(걷기·돌기)', '코디 3가지 빠른 컷', '가격/한정(사이트에 있으면)'], ['photoreal', 'ugc'], ['착용 실루엣', '소재·디테일'], ['비현실적 몸매 묘사', '타사 로고'], 'lively'),
|
|
23
|
+
ecommerce_food: P('ecommerce_food', '식품·음료·농산물', ['product_hero', 'ugc_selfie', 'lifestyle'], ['한 입·시즐(김·소리)', '산지/생산 장면 → 식탁', '「오늘 주문 → 내일 도착」(사이트 근거)'], ['photoreal', 'product_shot', 'ugc'], ['먹는 장면·질감', '원산지·신선함'], ['건강 효능 단정', '가격 오표기'], 'lively'),
|
|
24
|
+
education: P('education', '교육·강의·학원', ['text_hook', 'ugc_selfie', 'ui_demo'], ['「이 문제 풀 수 있어요?」', '수강생 셀카 후기(합격·성과는 근거 있을 때만)', '커리큘럼 첫 장면'], ['photoreal', 'motion_graphics', 'ugc'], ['무엇을 배우는지 3초', '강사/교재 실제 화면'], ['합격 보장', '타 기관 비교'], 'lively'),
|
|
25
|
+
travel: P('travel', '여행·숙박·투어', ['lifestyle', 'cinematic', 'ugc_selfie'], ['목적지 첫 장면 3개 빠른 컷', '「이 가격에?」(실제 가격만)', '여행자 셀카 리액션'], ['photoreal', 'ugc'], ['목적지·숙소·경험 장면', '가격·기간(사이트 근거)'], ['가짜 「마감 임박」', '타사 항공·호텔 로고'], 'lively'),
|
|
26
|
+
airline: P('airline', '항공', ['cinematic', 'lifestyle', 'text_hook'], ['이륙/창밖 → 도착지', '좌석·서비스 디테일', '「○○행 ○○원부터」(실제 운임만)'], ['photoreal'], ['노선·좌석·서비스', '운임 조건'], ['안전 관련 과장', '경쟁사 언급'], 'calm'),
|
|
27
|
+
realestate_construction: P('realestate_construction', '건설·부동산·인테리어', ['cinematic', 'text_hook', 'lifestyle'], ['완공 드론 → 내부 워크스루', '「이 동네에 이런 집?」', '시공 전→후(주거 허용·정직하게)'], ['photoreal', '3d'], ['실제 조감·평면·위치', '시공 품질'], ['수익률·시세 상승 단정', '허위 분양 조건'], 'calm'),
|
|
28
|
+
finance: P('finance', '금융·보험·투자', ['text_hook', 'ugc_selfie'], ['「매달 이만큼 새나가요」(근거 있을 때만)', '한 줄 질문'], ['photoreal', 'motion_graphics'], ['상품 조건·수수료'], ['수익 보장', '공포 소구'], 'calm'),
|
|
29
|
+
medical: P('medical', '의료·병원·시술', ['text_hook', 'cinematic'], ['시설·의료진(실존 인물 초상 동의 필요)'], ['photoreal'], ['진료 과목·위치'], ['전후 비교', '치료 효과 단정', '환자 후기 연출'], 'calm'),
|
|
30
|
+
b2b_service: P('b2b_service', 'B2B 서비스·컨설팅·제조', ['text_hook', 'ui_demo', 'cinematic'], ['「담당자님, 이거 아직도?」', '숫자 훅(근거)', '결과물 실물/화면'], ['photoreal', 'motion_graphics'], ['누구의 어떤 문제를 푸는지', '결과물·레퍼런스(공개 가능한 것)'], ['고객사 로고 무단 사용'], 'calm'),
|
|
31
|
+
local_service: P('local_service', '지역 서비스(음식점·미용·학원·병원 외)', ['ugc_selfie', 'lifestyle', 'product_hero'], ['매장 첫 장면 + 대표 메뉴/서비스', '사장님 한마디', '「○○역 3분」(사이트 근거)'], ['photoreal', 'ugc'], ['실제 매장·서비스 장면', '위치·영업시간'], ['가짜 후기'], 'lively'),
|
|
32
|
+
automotive: P('automotive', '자동차·모빌리티', ['cinematic', 'product_hero', 'lifestyle'], ['주행 첫 컷 → 디테일', '「월 ○○원」(실제 조건)'], ['photoreal', '3d'], ['외관·실내·주행'], ['안전 과장', '경쟁 모델 비방'], 'bold'),
|
|
33
|
+
fitness: P('fitness', '피트니스·운동·다이어트', ['ugc_selfie', 'lifestyle', 'text_hook'], ['운동 동작 첫 컷', '루틴 셀카', '「하루 10분」(프로그램 근거)'], ['photoreal', 'ugc'], ['운동 장면·프로그램'], ['전후 몸매 비교', '체중 감량 단정', '신체 수치 지칭'], 'bold'),
|
|
34
|
+
other: P('other', '기타', ['ugc_selfie', 'cinematic', 'text_hook'], ['질문 훅', '결과물 첫 장면', '한 문장 약속(근거)'], ['photoreal', 'ugc'], ['무엇을 파는지 3초 안에'], ['과장·최상급'], 'lively'),
|
|
35
|
+
};
|
|
36
|
+
export const INDUSTRY_KEYS = Object.keys(INDUSTRIES);
|
|
37
|
+
export function playbookFor(industry) { return INDUSTRIES[industry || ''] || INDUSTRIES.other; }
|
|
38
|
+
/** 이 브리프에 맞는 영상 포맷 n개 — 플레이북 순서를 따르되 장르(anime 등)·화면 유무를 반영 · 항상 서로 다른 포맷 */
|
|
39
|
+
export function pickFormats(o) {
|
|
40
|
+
if (o.requested?.length)
|
|
41
|
+
return [...new Set(o.requested)].slice(0, Math.max(1, o.count));
|
|
42
|
+
const pb = playbookFor(o.industry);
|
|
43
|
+
let order = [...pb.formats, 'cinematic', 'ugc_selfie', 'text_hook', 'product_hero', 'lifestyle'];
|
|
44
|
+
if (!o.hasScreens)
|
|
45
|
+
order = order.filter((f) => f !== 'ui_demo');
|
|
46
|
+
const anime = (o.genres || []).includes('anime') || (o.genres || []).includes('3d');
|
|
47
|
+
if (anime && !order.slice(0, 2).includes('cinematic'))
|
|
48
|
+
order = ['cinematic', ...order.filter((f) => f !== 'cinematic')];
|
|
49
|
+
return [...new Set(order)].slice(0, Math.max(1, o.count));
|
|
50
|
+
}
|
|
51
|
+
/** 장르 → 이미지/영상 프롬프트 스타일 토큰 */
|
|
52
|
+
export const GENRE_STYLE = {
|
|
53
|
+
photoreal: 'photorealistic, natural skin and materials, shot on a modern mirrorless camera, realistic lighting',
|
|
54
|
+
ugc: 'authentic user-generated smartphone footage look, handheld, natural indoor light, real-person casual vibe, no studio polish',
|
|
55
|
+
anime: 'high-quality Japanese anime style, clean linework, vivid cel shading, dramatic sky and light, studio-quality animation',
|
|
56
|
+
'3d': 'stylized 3D render, soft global illumination, Pixar-like character appeal, clean materials',
|
|
57
|
+
illustration: 'flat editorial illustration, bold shapes, limited palette, tasteful texture',
|
|
58
|
+
motion_graphics: 'kinetic typography and motion graphics, clean vector shapes, brand colors, smooth easing',
|
|
59
|
+
product_shot: 'studio product photography, macro detail, controlled lighting, clean backdrop',
|
|
60
|
+
};
|
|
61
|
+
export const genreStyle = (g) => GENRE_STYLE[g || ''] || GENRE_STYLE.photoreal;
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { Brief, Concept, CreativeAsset } from '../state.js';
|
|
2
2
|
import { type Size } from './specs.js';
|
|
3
|
-
export declare
|
|
3
|
+
export declare const FONT_FAMILY = "Pretendard, PretendardJP, NotoCJKtc, NotoCJKsc, NotoThai";
|
|
4
|
+
export declare function loadFonts(lang?: string): Promise<{
|
|
4
5
|
name: string;
|
|
5
6
|
data: ArrayBuffer;
|
|
6
7
|
weight: 400 | 500 | 700 | 800;
|
|
7
8
|
style: 'normal';
|
|
8
9
|
}[]>;
|
|
10
|
+
/** 🔴 확장자가 아니라 바이트로 MIME 판별 — Gemini 이미지 API 는 .png 요청에도 JPEG 를 돌려준다(2026-09-16 실측 · 잘못된 MIME 이면 satori 가 배경을 조용히 버림) */
|
|
11
|
+
export declare function sniffMime(buf: Buffer): string;
|
|
12
|
+
declare const dataUri: (file: string) => string;
|
|
13
|
+
export { dataUri };
|
|
9
14
|
type El = {
|
|
10
15
|
type: string;
|
|
11
16
|
props: Record<string, unknown>;
|
|
@@ -22,4 +27,20 @@ export declare function renderConcept(dir: string, brief: Brief, concept: Concep
|
|
|
22
27
|
}): Promise<CreativeAsset[]>;
|
|
23
28
|
/** 구글 RDA 로고 자산 — 사이트 로고가 있으면 그것을, 없으면 회사명 텍스트 로고를 1:1(512)·4:1(1200×300)로 만든다 */
|
|
24
29
|
export declare function renderLogo(dir: string, brief: Brief): Promise<CreativeAsset[]>;
|
|
25
|
-
|
|
30
|
+
/** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
|
|
31
|
+
export declare function renderOverlay(file: string, w: number, hh: number, text: string, o: {
|
|
32
|
+
kind: 'line' | 'cta' | 'hook';
|
|
33
|
+
primary: string;
|
|
34
|
+
lang?: string;
|
|
35
|
+
}): Promise<string>;
|
|
36
|
+
/** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
|
|
37
|
+
export declare function renderPhoneStage(dir: string, w: number, hh: number, primary: string, dark: string): Promise<{
|
|
38
|
+
bg: string;
|
|
39
|
+
frame: string;
|
|
40
|
+
screen: {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
w: number;
|
|
44
|
+
h: number;
|
|
45
|
+
};
|
|
46
|
+
}>;
|
|
@@ -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
|
-
|
|
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,9 +54,22 @@ 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 가 배경을 조용히 버림) */
|
|
66
|
+
export function sniffMime(buf) { if (buf[0] === 0x89 && buf[1] === 0x50)
|
|
67
|
+
return 'image/png'; if (buf[0] === 0xff && buf[1] === 0xd8)
|
|
68
|
+
return 'image/jpeg'; if (buf.subarray(0, 4).toString() === 'RIFF' && buf.subarray(8, 12).toString() === 'WEBP')
|
|
69
|
+
return 'image/webp'; if (/^\s*<(\?xml|svg)/.test(buf.subarray(0, 100).toString()))
|
|
70
|
+
return 'image/svg+xml'; return 'image/png'; }
|
|
71
|
+
const dataUri = (file) => { const buf = fs.readFileSync(file); return `data:${sniffMime(buf)};base64,${buf.toString('base64')}`; };
|
|
72
|
+
export { dataUri };
|
|
32
73
|
async function fetchLogo(url, dir) {
|
|
33
74
|
if (!url)
|
|
34
75
|
return null;
|
|
@@ -107,7 +148,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
107
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)]),
|
|
108
149
|
]));
|
|
109
150
|
}
|
|
110
|
-
return h('div', { width: w, height: hh, display: 'flex', position: 'relative', overflow: 'hidden', fontFamily:
|
|
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);
|
|
111
152
|
}
|
|
112
153
|
export const CTA_LABEL = {
|
|
113
154
|
ko: { LEARN_MORE: '자세히 보기', SIGN_UP: '지금 시작하기', SHOP_NOW: '지금 구매', CONTACT_US: '문의하기', DOWNLOAD: '내려받기', GET_OFFER: '혜택 받기', GET_QUOTE: '견적 받기', SUBSCRIBE: '구독하기', APPLY_NOW: '지금 신청', ORDER_NOW: '지금 주문', BUY_NOW: '지금 구매', BOOK_TRAVEL: '예약하기' },
|
|
@@ -117,7 +158,7 @@ export const CTA_LABEL = {
|
|
|
117
158
|
export function ctaLabel(cta, lang) { return CTA_LABEL[lang]?.[cta] || CTA_LABEL.en[cta] || CTA_LABEL.en.LEARN_MORE; }
|
|
118
159
|
/** 컨셉 하나를 12규격으로 렌더 → 파일 목록. 이미 있는 파일은 건너뜀(--force로 다시). */
|
|
119
160
|
export async function renderConcept(dir, brief, concept, backgrounds, opts = {}) {
|
|
120
|
-
const fonts = await loadFonts();
|
|
161
|
+
const fonts = await loadFonts(brief.language);
|
|
121
162
|
const logo = await fetchLogo(brief.logoUrl, dir);
|
|
122
163
|
const out = [];
|
|
123
164
|
const ctaText = ctaLabel(concept.cta, brief.language);
|
|
@@ -145,7 +186,7 @@ export async function renderLogo(dir, brief) {
|
|
|
145
186
|
for (const [w, hh] of [[512, 512], [1200, 300]]) {
|
|
146
187
|
const file = path.join(dir, `_logo_${w}x${hh}.png`);
|
|
147
188
|
if (!fs.existsSync(file)) {
|
|
148
|
-
const el = h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: brief.palette.primary, fontFamily:
|
|
189
|
+
const el = h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: brief.palette.primary, fontFamily: FONT_FAMILY }, [
|
|
149
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)),
|
|
150
191
|
]);
|
|
151
192
|
const svg = await satori(el, { width: w, height: hh, fonts });
|
|
@@ -155,3 +196,37 @@ export async function renderLogo(dir, brief) {
|
|
|
155
196
|
}
|
|
156
197
|
return out;
|
|
157
198
|
}
|
|
199
|
+
/** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
|
|
200
|
+
export async function renderOverlay(file, w, hh, text, o) {
|
|
201
|
+
const fonts = await loadFonts(o.lang);
|
|
202
|
+
const base = Math.min(w, hh);
|
|
203
|
+
const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
|
|
204
|
+
const pill = o.kind === 'cta'
|
|
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)
|
|
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);
|
|
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
|
+
? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: Math.round(hh * 0.14), fontFamily: FONT_FAMILY }, [hookEl])
|
|
212
|
+
: 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
|
+
const svg = await satori(el, { width: w, height: hh, fonts });
|
|
214
|
+
fs.writeFileSync(file, new Resvg(svg, { fitTo: { mode: 'width', value: w } }).render().asPng());
|
|
215
|
+
return file;
|
|
216
|
+
}
|
|
217
|
+
/** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
|
|
218
|
+
export async function renderPhoneStage(dir, w, hh, primary, dark) {
|
|
219
|
+
const fonts = await loadFonts();
|
|
220
|
+
const sw = Math.round(w * 0.62);
|
|
221
|
+
const sh = Math.round(sw * 2.05);
|
|
222
|
+
const sx = Math.round((w - sw) / 2);
|
|
223
|
+
const sy = Math.round((hh - sh) / 2);
|
|
224
|
+
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 });
|
|
225
|
+
const bg = path.join(dir, `stage_bg_${w}x${hh}.png`);
|
|
226
|
+
fs.writeFileSync(bg, new Resvg(bgSvg).render().asPng());
|
|
227
|
+
const bezel = Math.round(sw * 0.045);
|
|
228
|
+
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>`;
|
|
229
|
+
const frame = path.join(dir, `stage_frame_${w}x${hh}.png`);
|
|
230
|
+
fs.writeFileSync(frame, new Resvg(frameSvg).render().asPng());
|
|
231
|
+
return { bg, frame, screen: { x: sx, y: sy, w: sw, h: sh } };
|
|
232
|
+
}
|
|
@@ -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 는 8초에 맞는 한 문장(${lang}) · 프롬프트에 "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단어 이내. 브랜드명(${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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type VideoRatio } from './media.js';
|
|
2
|
+
export type VideoGenInput = {
|
|
3
|
+
file: string;
|
|
4
|
+
prompt: string;
|
|
5
|
+
ratio: VideoRatio;
|
|
6
|
+
durationSec: number;
|
|
7
|
+
model: string;
|
|
8
|
+
resolution?: '720p' | '1080p';
|
|
9
|
+
audio?: boolean;
|
|
10
|
+
firstFrame?: string;
|
|
11
|
+
refs?: string[];
|
|
12
|
+
negative?: string;
|
|
13
|
+
log?: (s: string) => void;
|
|
14
|
+
};
|
|
15
|
+
export type VideoGenResult = {
|
|
16
|
+
file: string;
|
|
17
|
+
costKrw: number;
|
|
18
|
+
provider: 'veo' | 'seedance' | 'mock';
|
|
19
|
+
model: string;
|
|
20
|
+
durationSec: number;
|
|
21
|
+
opId?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function videoConfig(): {
|
|
24
|
+
veo?: {
|
|
25
|
+
base: string;
|
|
26
|
+
key: string;
|
|
27
|
+
};
|
|
28
|
+
seedance?: {
|
|
29
|
+
key: string;
|
|
30
|
+
base: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
export declare function videoAvailable(provider?: 'veo' | 'seedance'): boolean;
|
|
34
|
+
/** 영상 한 편 — 모델의 공급자로 라우팅. 실패는 예외(fatal 이면 재시도 없이 · costKrw 가 붙어 오면 그만큼은 원가 발생). */
|
|
35
|
+
export declare function generateVideo(i: VideoGenInput): Promise<VideoGenResult>;
|