adyou 0.3.0 → 0.4.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 +103 -37
- package/dist/adapters/mock.js +17 -7
- package/dist/adapters/types.d.ts +3 -0
- package/dist/adapters/types.js +2 -0
- package/dist/cli.js +2 -2
- package/dist/core/creatives/copy.d.ts +12 -3
- package/dist/core/creatives/copy.js +27 -6
- package/dist/core/creatives/factory.d.ts +22 -0
- package/dist/core/creatives/factory.js +112 -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 +108 -0
- package/dist/core/creatives/media.js +82 -0
- package/dist/core/creatives/render.d.ts +9 -1
- package/dist/core/creatives/render.js +21 -1
- package/dist/core/creatives/video.d.ts +35 -0
- package/dist/core/creatives/video.js +177 -0
- package/dist/core/creatives/videofx.d.ts +28 -0
- package/dist/core/creatives/videofx.js +86 -0
- package/dist/core/llm.js +9 -2
- package/dist/core/ops.d.ts +6 -0
- package/dist/core/ops.js +28 -6
- package/dist/core/state.d.ts +21 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp.js +2 -2
- package/package.json +1 -1
|
@@ -1,79 +1,148 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 배경·키비주얼 이미지 — BizRouter 이미지 API(OpenAI Images 호환 · gemini/gpt-image 최신 모델 전부 한 키)로 비율 4종 생성.
|
|
2
|
+
// 기본 모델은 media.ts 의 티어(economy=gemini-3.1-flash-lite-image · standard=gemini-3.1-flash-image · premium=gemini-3-pro-image).
|
|
3
|
+
// 참고 이미지(guided)가 있으면 /v1/images/edits(multipart image[]) 로 그 인물·제품·화풍을 유지한다.
|
|
4
|
+
// 응답 usage.cost(원) 가 원가 — 호출자가 ×1.1 로 청구한다. 키가 없으면 null → 렌더러가 브랜드 그래디언트로 대신한다.
|
|
5
|
+
// BizRouter 키가 없고 OPENAI_API_KEY 만 있으면 OpenAI 직행(gpt-image · size 만 지원 · 원가는 추정치).
|
|
6
|
+
// ADPILOT_MOCK_GEN=1 이면 API 없이 단색 PNG 를 만든다(e2e).
|
|
2
7
|
import fs from 'node:fs';
|
|
3
8
|
import path from 'node:path';
|
|
9
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
4
10
|
import { loadConfig } from '../state.js';
|
|
11
|
+
import { llmConfig } from '../llm.js';
|
|
5
12
|
import { BG_RATIOS } from './specs.js';
|
|
13
|
+
import { IMAGE_MODELS } from './media.js';
|
|
6
14
|
export function imageConfig() {
|
|
7
15
|
const c = loadConfig().image || {};
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
const llm = llmConfig();
|
|
17
|
+
const brKey = process.env.ADPILOT_IMAGE_KEY || (c.provider !== 'openai' ? c.apiKey : undefined) || (llm && /bizrouter/.test(llm.base) ? llm.key : undefined);
|
|
18
|
+
if (brKey)
|
|
19
|
+
return { key: brKey, base: (process.env.ADPILOT_IMAGE_BASE_URL || c.baseUrl || 'https://api.bizrouter.ai').replace(/\/+$/, ''), model: process.env.ADPILOT_IMAGE_MODEL || c.model || IMAGE_MODELS.economy.model, provider: 'bizrouter' };
|
|
20
|
+
const oa = process.env.OPENAI_API_KEY || (c.provider === 'openai' ? c.apiKey : undefined);
|
|
21
|
+
if (oa)
|
|
22
|
+
return { key: oa, base: 'https://api.openai.com', model: process.env.ADPILOT_IMAGE_MODEL || 'gpt-image-2.5-flare', provider: 'openai' };
|
|
23
|
+
return null;
|
|
12
24
|
}
|
|
13
|
-
export function imagesAvailable() { return imageConfig() !== null; }
|
|
25
|
+
export function imagesAvailable() { return process.env.ADPILOT_MOCK_GEN === '1' || imageConfig() !== null; }
|
|
26
|
+
export const isMockGen = () => process.env.ADPILOT_MOCK_GEN === '1';
|
|
14
27
|
const STYLE = 'Premium advertising key visual, cinematic, editorial quality, photoreal render or refined abstract. ABSOLUTELY NO text, letters, numbers, logos, watermarks, UI, or real people\'s faces. Composition leaves large calm negative space for a headline overlay.';
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
const REF_STYLE = 'Use the attached reference image(s) as the source of truth for the product, character, and visual style — keep them recognizable and consistent. Do not add text, logos, or watermarks.';
|
|
29
|
+
const gptSize = (ratio) => (ratio === '16x9' ? '1536x1024' : ratio === '1x1' ? '1024x1024' : '1024x1536');
|
|
30
|
+
const isGpt = (model) => /gpt-image|grok-imagine-image/.test(model);
|
|
31
|
+
/** 모의 이미지 — 비율에 맞는 그래디언트 PNG */
|
|
32
|
+
function mockPng(file, ratio, seed) {
|
|
33
|
+
const [w, h] = (BG_RATIOS[ratio]?.openai || '1024x1024').split('x').map(Number);
|
|
34
|
+
let n = 0;
|
|
35
|
+
for (const ch of seed)
|
|
36
|
+
n = (n * 31 + ch.charCodeAt(0)) >>> 0;
|
|
37
|
+
const hue = n % 360;
|
|
38
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue},60%,35%)"/><stop offset="1" stop-color="hsl(${(hue + 60) % 360},70%,55%)"/></linearGradient></defs><rect width="${w}" height="${h}" fill="url(#g)"/><circle cx="${w * 0.7}" cy="${h * 0.35}" r="${Math.min(w, h) * 0.22}" fill="rgba(255,255,255,.18)"/></svg>`;
|
|
39
|
+
fs.writeFileSync(file, new Resvg(svg).render().asPng());
|
|
40
|
+
}
|
|
41
|
+
/** 이미지 한 장 — 파일로 저장 · 원가(원) 반환. 실패는 예외(fatal 이면 재시도 없이) */
|
|
42
|
+
export async function generateImage(opts) {
|
|
43
|
+
const log = opts.log || (() => { });
|
|
44
|
+
if (isMockGen()) {
|
|
45
|
+
mockPng(opts.file, opts.ratio, opts.prompt + opts.ratio);
|
|
46
|
+
return { file: opts.file, costKrw: 50, model: 'mock' };
|
|
47
|
+
}
|
|
17
48
|
const cfg = imageConfig();
|
|
49
|
+
if (!cfg)
|
|
50
|
+
throw Object.assign(new Error('이미지 생성 키가 없어요'), { fatal: true });
|
|
51
|
+
let model = opts.model || cfg.model;
|
|
52
|
+
if (cfg.provider === 'openai' && !isGpt(model))
|
|
53
|
+
model = 'gpt-image-2.5-flare';
|
|
54
|
+
const refs = (opts.refs || []).filter((f) => fs.existsSync(f)).slice(0, 6);
|
|
55
|
+
const prompt = refs.length ? `${opts.prompt} ${REF_STYLE} ${STYLE.replace('ABSOLUTELY NO text', 'No text')}` : `${opts.prompt} ${STYLE}`;
|
|
56
|
+
const aspect = BG_RATIOS[opts.ratio]?.aspect || '1:1';
|
|
57
|
+
let lastErr;
|
|
58
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
let res;
|
|
61
|
+
if (refs.length && cfg.provider === 'bizrouter') {
|
|
62
|
+
const fd = new FormData();
|
|
63
|
+
fd.set('model', model);
|
|
64
|
+
fd.set('prompt', prompt);
|
|
65
|
+
fd.set('output_format', 'png');
|
|
66
|
+
if (isGpt(model)) {
|
|
67
|
+
fd.set('size', gptSize(opts.ratio));
|
|
68
|
+
fd.set('quality', opts.quality || 'medium');
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
fd.set('aspect_ratio', aspect);
|
|
72
|
+
for (const f of refs)
|
|
73
|
+
fd.append('image[]', new Blob([fs.readFileSync(f)], { type: /\.png$/i.test(f) ? 'image/png' : /\.webp$/i.test(f) ? 'image/webp' : 'image/jpeg' }), path.basename(f));
|
|
74
|
+
res = await fetch(`${cfg.base}/v1/images/edits`, { method: 'POST', headers: { authorization: `Bearer ${cfg.key}` }, body: fd, signal: AbortSignal.timeout(300_000) });
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const body = { model, prompt, output_format: 'png', n: 1 };
|
|
78
|
+
if (isGpt(model) || cfg.provider === 'openai') {
|
|
79
|
+
body.size = gptSize(opts.ratio);
|
|
80
|
+
body.quality = opts.quality || 'medium';
|
|
81
|
+
if (cfg.provider === 'openai')
|
|
82
|
+
delete body.output_format;
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
body.aspect_ratio = aspect;
|
|
86
|
+
res = await fetch(`${cfg.base}/v1/images/generations`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(300_000) });
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const t = await res.text();
|
|
90
|
+
if (res.status === 400 && /model|not found|does not exist/i.test(t) && model !== IMAGE_MODELS.economy.model && cfg.provider === 'bizrouter') {
|
|
91
|
+
log(` 모델 ${model} 을 쓸 수 없어 ${IMAGE_MODELS.economy.model} 로`);
|
|
92
|
+
model = IMAGE_MODELS.economy.model;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (res.status === 400 || res.status === 401 || res.status === 403 || res.status === 402)
|
|
96
|
+
throw Object.assign(new Error(`${res.status} ${t.slice(0, 200)}`), { fatal: true });
|
|
97
|
+
throw new Error(`${res.status} ${t.slice(0, 200)}`);
|
|
98
|
+
}
|
|
99
|
+
const d = (await res.json());
|
|
100
|
+
if (d.error)
|
|
101
|
+
throw Object.assign(new Error(d.error.message || '이미지 응답 오류'), { fatal: true });
|
|
102
|
+
const b64 = d.data?.[0]?.b64_json;
|
|
103
|
+
if (b64)
|
|
104
|
+
fs.writeFileSync(opts.file, Buffer.from(b64, 'base64'));
|
|
105
|
+
else if (d.data?.[0]?.url)
|
|
106
|
+
fs.writeFileSync(opts.file, Buffer.from(await (await fetch(d.data[0].url)).arrayBuffer()));
|
|
107
|
+
else
|
|
108
|
+
throw new Error('이미지 응답이 비었어요');
|
|
109
|
+
const costKrw = typeof d.usage?.cost === 'number' ? d.usage.cost : cfg.provider === 'openai' ? 180 : (Object.values(IMAGE_MODELS).find((m) => m.model === model)?.unitKrw ?? 100);
|
|
110
|
+
return { file: opts.file, costKrw, model };
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
lastErr = e;
|
|
114
|
+
if (e.fatal)
|
|
115
|
+
throw e;
|
|
116
|
+
log(` 이미지 생성 재시도 (${e instanceof Error ? e.message.slice(0, 120) : e})`);
|
|
117
|
+
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
121
|
+
}
|
|
122
|
+
/** 컨셉 × 비율 배경 생성 → 파일 경로. 이미 있으면 건너뜀(재실행 안전). 원가는 onAsset 으로 알린다. */
|
|
123
|
+
export async function generateBackgrounds(dir, conceptKey, prompt, ratios = Object.keys(BG_RATIOS), log = () => { }, opts = {}) {
|
|
18
124
|
const out = {};
|
|
125
|
+
const available = imagesAvailable();
|
|
19
126
|
const one = async (r) => {
|
|
20
|
-
const file = path.join(dir, `bg_${conceptKey}_${r}.png`);
|
|
127
|
+
const file = path.join(dir, `bg_${conceptKey}${opts.variant ? `_${opts.variant}` : ''}_${r}.png`);
|
|
21
128
|
if (fs.existsSync(file)) {
|
|
22
129
|
out[r] = file;
|
|
23
130
|
return;
|
|
24
131
|
}
|
|
25
|
-
if (!
|
|
132
|
+
if (!available) {
|
|
26
133
|
out[r] = null;
|
|
27
134
|
return;
|
|
28
135
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const next = FALLBACK.find((m) => m !== model && FALLBACK.indexOf(m) > FALLBACK.indexOf(model)) || FALLBACK.find((m) => m !== model);
|
|
39
|
-
if (next) {
|
|
40
|
-
log(` ${conceptKey} ${r}: 모델 ${model} 없음 → ${next}`);
|
|
41
|
-
model = next;
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
if (res.status === 400 && /size/i.test(t) && size !== '1024x1024') {
|
|
46
|
-
log(` ${conceptKey} ${r}: 이 모델은 ${size} 를 지원하지 않아요 → 1024x1024`);
|
|
47
|
-
size = '1024x1024';
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (res.status === 400 || res.status === 401 || res.status === 403)
|
|
51
|
-
throw Object.assign(new Error(`${res.status} ${t.slice(0, 200)}`), { fatal: true });
|
|
52
|
-
throw new Error(`${res.status} ${t.slice(0, 200)}`);
|
|
53
|
-
}
|
|
54
|
-
const d = (await res.json());
|
|
55
|
-
const b64 = d.data[0]?.b64_json;
|
|
56
|
-
if (b64)
|
|
57
|
-
fs.writeFileSync(file, Buffer.from(b64, 'base64'));
|
|
58
|
-
else if (d.data[0]?.url)
|
|
59
|
-
fs.writeFileSync(file, Buffer.from(await (await fetch(d.data[0].url)).arrayBuffer()));
|
|
60
|
-
else
|
|
61
|
-
throw new Error('이미지 응답이 비었어요');
|
|
62
|
-
out[r] = file;
|
|
63
|
-
log(` 배경 ${conceptKey} ${r} ✓`);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
catch (e) {
|
|
67
|
-
if (e.fatal) {
|
|
68
|
-
log(` ${conceptKey} ${r}: 이미지 생성 실패 — ${e.message.slice(0, 160)} (브랜드 그래디언트로 대체)`);
|
|
69
|
-
out[r] = null;
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
log(` ${conceptKey} ${r}: 이미지 생성 재시도 (${e instanceof Error ? e.message.slice(0, 120) : e})`);
|
|
73
|
-
await new Promise((r2) => setTimeout(r2, 3000 * (attempt + 1)));
|
|
74
|
-
}
|
|
136
|
+
try {
|
|
137
|
+
const res = await generateImage({ file, prompt, ratio: r, model: opts.model, refs: opts.refs, log });
|
|
138
|
+
out[r] = file;
|
|
139
|
+
log(` 배경 ${conceptKey}${opts.variant ? `/${opts.variant}` : ''} ${r} ✓ (₩${Math.round(res.costKrw)})`);
|
|
140
|
+
await opts.onAsset?.({ ratio: r, file, costKrw: res.costKrw, model: res.model });
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
log(` ${conceptKey} ${r}: 이미지 생성 실패 — ${e.message.slice(0, 160)} (브랜드 그래디언트로 대체)`);
|
|
144
|
+
out[r] = null;
|
|
75
145
|
}
|
|
76
|
-
out[r] = null;
|
|
77
146
|
};
|
|
78
147
|
await Promise.all(ratios.map(one));
|
|
79
148
|
return out;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Medium } from '../../adapters/types.js';
|
|
2
|
+
export type CreativeMode = 'auto' | 'image' | 'video' | 'text';
|
|
3
|
+
export type CreativeSource = 'ai' | 'guided' | 'manual';
|
|
4
|
+
export type Tier = 'economy' | 'standard' | 'premium';
|
|
5
|
+
export type VideoRatio = '9x16' | '16x9' | '1x1' | '4x5';
|
|
6
|
+
export type VideoProvider = 'auto' | 'veo' | 'seedance';
|
|
7
|
+
export type VideoDuration = 8 | 15 | 30;
|
|
8
|
+
export type CreativeSpec = {
|
|
9
|
+
mode?: CreativeMode;
|
|
10
|
+
source?: CreativeSource;
|
|
11
|
+
/** guided: 요구사항·방향(자연어). manual 에서도 참고용으로 남는다 */
|
|
12
|
+
prompt?: string;
|
|
13
|
+
/** 컨셉(방향) 수 1~5 — 비우면 모드별 기본 */
|
|
14
|
+
concepts?: number;
|
|
15
|
+
images?: {
|
|
16
|
+
tier?: Tier;
|
|
17
|
+
variants?: number;
|
|
18
|
+
};
|
|
19
|
+
video?: {
|
|
20
|
+
count?: number;
|
|
21
|
+
durationSec?: VideoDuration;
|
|
22
|
+
ratios?: VideoRatio[];
|
|
23
|
+
audio?: boolean;
|
|
24
|
+
tier?: Tier;
|
|
25
|
+
captions?: boolean;
|
|
26
|
+
endCard?: boolean;
|
|
27
|
+
provider?: VideoProvider;
|
|
28
|
+
resolution?: '720p' | '1080p';
|
|
29
|
+
};
|
|
30
|
+
/** manual: 광고주가 직접 올린 문구 */
|
|
31
|
+
copy?: {
|
|
32
|
+
headlines?: string[];
|
|
33
|
+
bodies?: string[];
|
|
34
|
+
descriptions?: string[];
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export type VideoJob = {
|
|
38
|
+
ratio: VideoRatio;
|
|
39
|
+
durationSec: number;
|
|
40
|
+
provider: 'veo' | 'seedance';
|
|
41
|
+
model: string;
|
|
42
|
+
resolution: '720p' | '1080p';
|
|
43
|
+
audio: boolean;
|
|
44
|
+
captions: boolean;
|
|
45
|
+
endCard: boolean;
|
|
46
|
+
unitKrw: number;
|
|
47
|
+
};
|
|
48
|
+
export type CreativePlan = {
|
|
49
|
+
mode: CreativeMode;
|
|
50
|
+
source: CreativeSource;
|
|
51
|
+
concepts: number;
|
|
52
|
+
images: {
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
tier: Tier;
|
|
55
|
+
model: string;
|
|
56
|
+
variants: number;
|
|
57
|
+
ratios: string[];
|
|
58
|
+
unitKrw: number;
|
|
59
|
+
};
|
|
60
|
+
videos: {
|
|
61
|
+
perConcept: VideoJob[];
|
|
62
|
+
conceptsWithVideo: number;
|
|
63
|
+
};
|
|
64
|
+
estimate: {
|
|
65
|
+
imagesKrw: number;
|
|
66
|
+
videosKrw: number;
|
|
67
|
+
totalKrw: number;
|
|
68
|
+
chargedKrw: number;
|
|
69
|
+
lines: string[];
|
|
70
|
+
};
|
|
71
|
+
explain: string;
|
|
72
|
+
};
|
|
73
|
+
/** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
|
|
74
|
+
export declare const CREATIVE_MARKUP = 1.1;
|
|
75
|
+
export declare const chargeFor: (costKrw: number) => number;
|
|
76
|
+
/** 달러 → 원. BizRouter 모델표가 쓰는 환율(1436)과 맞춘다 · 환경변수로 덮어쓸 수 있다 */
|
|
77
|
+
export declare const USD_KRW: () => number;
|
|
78
|
+
export declare const IMAGE_MODELS: Record<Tier, {
|
|
79
|
+
model: string;
|
|
80
|
+
unitKrw: number;
|
|
81
|
+
}>;
|
|
82
|
+
/** 영상 초당 원가(원) — 8초 이하는 Veo(BizRouter 한 키) · 15/30초는 Seedance 2.5(ARK) */
|
|
83
|
+
export declare const VIDEO_MODELS: Record<string, {
|
|
84
|
+
provider: 'veo' | 'seedance';
|
|
85
|
+
perSecKrw: Record<'720p' | '1080p', number>;
|
|
86
|
+
maxSec: number;
|
|
87
|
+
ratios: VideoRatio[];
|
|
88
|
+
}>;
|
|
89
|
+
export declare function pickVideoModel(durationSec: number, tier: Tier, provider?: VideoProvider, ratio?: VideoRatio): string;
|
|
90
|
+
export declare const MODE_LABEL: Record<CreativeMode, string>;
|
|
91
|
+
export declare const SOURCE_LABEL: Record<CreativeSource, string>;
|
|
92
|
+
/**
|
|
93
|
+
* 설정 + 예산 → 실행 계획. 온라인 광고 소재의 일반 배합:
|
|
94
|
+
* - 이미지는 컨셉마다 비율 4종(1:1·4:5·9:16·16:9) 배경을 만들어 12규격으로 합성(기존) · 「이미지 위주」는 배경 2세트(A/B)
|
|
95
|
+
* - 영상은 9:16(릴스·스토리·쇼츠) 8초가 기본 · 「영상 위주」는 16:9(피드·유튜브) 추가 · 1:1/4:5 는 9:16 에서 무료로 잘라 만든다
|
|
96
|
+
* - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
|
|
97
|
+
* - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolveCreativePlan(spec: CreativeSpec | undefined, ctx: {
|
|
100
|
+
monthlyKrw: number;
|
|
101
|
+
media: Medium[];
|
|
102
|
+
}): CreativePlan;
|
|
103
|
+
/** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
|
|
104
|
+
export declare const DERIVED_RATIOS: Record<VideoRatio, VideoRatio[]>;
|
|
105
|
+
export declare const VIDEO_DIMS: Record<VideoRatio, {
|
|
106
|
+
w: number;
|
|
107
|
+
h: number;
|
|
108
|
+
}>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
|
|
2
|
+
export const CREATIVE_MARKUP = 1.1;
|
|
3
|
+
export const chargeFor = (costKrw) => Math.ceil(Math.max(0, costKrw) * CREATIVE_MARKUP);
|
|
4
|
+
/** 달러 → 원. BizRouter 모델표가 쓰는 환율(1436)과 맞춘다 · 환경변수로 덮어쓸 수 있다 */
|
|
5
|
+
export const USD_KRW = () => Number(process.env.USD_KRW || 1436);
|
|
6
|
+
export const IMAGE_MODELS = {
|
|
7
|
+
economy: { model: 'google/gemini-3.1-flash-lite-image', unitKrw: 55 },
|
|
8
|
+
standard: { model: 'google/gemini-3.1-flash-image', unitKrw: 105 },
|
|
9
|
+
premium: { model: 'google/gemini-3-pro-image', unitKrw: 240 },
|
|
10
|
+
};
|
|
11
|
+
/** 영상 초당 원가(원) — 8초 이하는 Veo(BizRouter 한 키) · 15/30초는 Seedance 2.5(ARK) */
|
|
12
|
+
export const VIDEO_MODELS = {
|
|
13
|
+
'google/veo-3.1-lite': { provider: 'veo', perSecKrw: { '720p': 72, '1080p': 115 }, maxSec: 8, ratios: ['9x16', '16x9'] },
|
|
14
|
+
'google/veo-3.1-fast': { provider: 'veo', perSecKrw: { '720p': 144, '1080p': 173 }, maxSec: 8, ratios: ['9x16', '16x9'] },
|
|
15
|
+
'google/veo-3.1': { provider: 'veo', perSecKrw: { '720p': 575, '1080p': 575 }, maxSec: 8, ratios: ['9x16', '16x9'] },
|
|
16
|
+
'dreamina-seedance-2-5-260628': { provider: 'seedance', perSecKrw: { '720p': 335, '1080p': 820 }, maxSec: 30, ratios: ['9x16', '16x9', '1x1', '4x5'] },
|
|
17
|
+
};
|
|
18
|
+
const VEO_BY_TIER = { economy: 'google/veo-3.1-lite', standard: 'google/veo-3.1-fast', premium: 'google/veo-3.1' };
|
|
19
|
+
export function pickVideoModel(durationSec, tier, provider = 'auto', ratio = '9x16') {
|
|
20
|
+
if (provider === 'seedance')
|
|
21
|
+
return 'dreamina-seedance-2-5-260628';
|
|
22
|
+
if (provider === 'veo')
|
|
23
|
+
return VEO_BY_TIER[tier];
|
|
24
|
+
// auto: 8초·9:16/16:9 는 Veo(싸고 한 키) · 그 외(15/30초 · 1:1·4:5 원본)는 Seedance
|
|
25
|
+
if (durationSec <= 8 && (ratio === '9x16' || ratio === '16x9'))
|
|
26
|
+
return VEO_BY_TIER[tier];
|
|
27
|
+
return 'dreamina-seedance-2-5-260628';
|
|
28
|
+
}
|
|
29
|
+
export const MODE_LABEL = { auto: '알아서(추천)', image: '이미지 위주', video: '영상 위주', text: '글·링크 위주' };
|
|
30
|
+
export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구사항·참고 이미지로', manual: '직접 올린 소재로' };
|
|
31
|
+
/**
|
|
32
|
+
* 설정 + 예산 → 실행 계획. 온라인 광고 소재의 일반 배합:
|
|
33
|
+
* - 이미지는 컨셉마다 비율 4종(1:1·4:5·9:16·16:9) 배경을 만들어 12규격으로 합성(기존) · 「이미지 위주」는 배경 2세트(A/B)
|
|
34
|
+
* - 영상은 9:16(릴스·스토리·쇼츠) 8초가 기본 · 「영상 위주」는 16:9(피드·유튜브) 추가 · 1:1/4:5 는 9:16 에서 무료로 잘라 만든다
|
|
35
|
+
* - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
|
|
36
|
+
* - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
|
|
37
|
+
*/
|
|
38
|
+
export function resolveCreativePlan(spec, ctx) {
|
|
39
|
+
const s = spec || {};
|
|
40
|
+
const mode = s.mode || 'auto';
|
|
41
|
+
const source = s.source || 'ai';
|
|
42
|
+
const small = ctx.monthlyKrw < 500_000;
|
|
43
|
+
const concepts = Math.max(1, Math.min(5, s.concepts || (source === 'manual' ? 1 : mode === 'text' ? 4 : 3)));
|
|
44
|
+
const imgTier = s.images?.tier || (ctx.monthlyKrw >= 3_000_000 ? 'standard' : 'economy');
|
|
45
|
+
const imagesEnabled = source !== 'manual' && mode !== 'text';
|
|
46
|
+
const variants = Math.max(1, Math.min(3, s.images?.variants || (mode === 'image' ? 2 : 1)));
|
|
47
|
+
const ratios = ['1x1', '4x5', '9x16', '16x9'];
|
|
48
|
+
const vTier = s.video?.tier || (ctx.monthlyKrw >= 2_000_000 ? 'standard' : 'economy');
|
|
49
|
+
const duration = s.video?.durationSec || 8;
|
|
50
|
+
const wantVideo = source !== 'manual' && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0) && mode !== 'text' && mode !== 'image';
|
|
51
|
+
const vRatios = s.video?.ratios?.length ? s.video.ratios : mode === 'video' ? ['9x16', '16x9'] : ['9x16'];
|
|
52
|
+
const count = wantVideo ? Math.max(0, Math.min(4, s.video?.count ?? vRatios.length)) : 0;
|
|
53
|
+
const resolution = s.video?.resolution || '720p';
|
|
54
|
+
const perConcept = [];
|
|
55
|
+
for (let i = 0; i < count; i++) {
|
|
56
|
+
const ratio = vRatios[i % vRatios.length];
|
|
57
|
+
const model = pickVideoModel(duration, vTier, s.video?.provider || 'auto', ratio);
|
|
58
|
+
const m = VIDEO_MODELS[model];
|
|
59
|
+
const sec = Math.min(duration, m.maxSec);
|
|
60
|
+
perConcept.push({ ratio, durationSec: sec, provider: m.provider, model, resolution, audio: s.video?.audio ?? true, captions: s.video?.captions ?? true, endCard: s.video?.endCard ?? true, unitKrw: Math.round(m.perSecKrw[resolution] * sec) });
|
|
61
|
+
}
|
|
62
|
+
const conceptsWithVideo = count ? (small && mode !== 'video' ? 1 : concepts) : 0;
|
|
63
|
+
const img = IMAGE_MODELS[imgTier];
|
|
64
|
+
const imagesKrw = imagesEnabled ? concepts * variants * ratios.length * img.unitKrw : 0;
|
|
65
|
+
const videosKrw = conceptsWithVideo * perConcept.reduce((n, v) => n + v.unitKrw, 0);
|
|
66
|
+
const totalKrw = imagesKrw + videosKrw;
|
|
67
|
+
const lines = [];
|
|
68
|
+
if (imagesEnabled)
|
|
69
|
+
lines.push(`이미지 배경 ${concepts * variants * ratios.length}장(방향 ${concepts} × 세트 ${variants} × 비율 4) ≈ ₩${imagesKrw.toLocaleString()} → 12가지 크기로 합성(합성은 무료)`);
|
|
70
|
+
else if (source === 'manual')
|
|
71
|
+
lines.push('이미지·영상은 올려 주신 것을 그대로 써요(생성 비용 0)');
|
|
72
|
+
else
|
|
73
|
+
lines.push('이미지 생성 없이 브랜드 색 배경 + 문구로 만들어요(비용 0)');
|
|
74
|
+
if (conceptsWithVideo)
|
|
75
|
+
lines.push(`영상 ${conceptsWithVideo * perConcept.length}개(방향 ${conceptsWithVideo} × ${perConcept.map((v) => `${v.ratio.replace('x', ':')} ${v.durationSec}초`).join('·')}) ≈ ₩${videosKrw.toLocaleString()}${perConcept.some((v) => v.audio) ? ' · 소리 포함' : ''}`);
|
|
76
|
+
lines.push(`예상 원가 ₩${totalKrw.toLocaleString()} → 청구 ₩${chargeFor(totalKrw).toLocaleString()}(원가의 1.1배 · 실제 생성된 것만, 만들어진 뒤 정확한 원가로 차감)`);
|
|
77
|
+
const explain = [`소재 형태: ${MODE_LABEL[mode]} · 출처: ${SOURCE_LABEL[source]}`, ...lines].join('\n');
|
|
78
|
+
return { mode, source, concepts, images: { enabled: imagesEnabled, tier: imgTier, model: img.model, variants, ratios, unitKrw: img.unitKrw }, videos: { perConcept, conceptsWithVideo }, estimate: { imagesKrw, videosKrw, totalKrw, chargedKrw: chargeFor(totalKrw), lines }, explain };
|
|
79
|
+
}
|
|
80
|
+
/** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
|
|
81
|
+
export const DERIVED_RATIOS = { '9x16': ['4x5', '1x1'], '16x9': ['1x1'], '1x1': [], '4x5': [] };
|
|
82
|
+
export const VIDEO_DIMS = { '9x16': { w: 1080, h: 1920 }, '16x9': { w: 1920, h: 1080 }, '1x1': { w: 1080, h: 1080 }, '4x5': { w: 1080, h: 1350 } };
|
|
@@ -6,6 +6,10 @@ export declare function loadFonts(): Promise<{
|
|
|
6
6
|
weight: 400 | 500 | 700 | 800;
|
|
7
7
|
style: 'normal';
|
|
8
8
|
}[]>;
|
|
9
|
+
/** 🔴 확장자가 아니라 바이트로 MIME 판별 — Gemini 이미지 API 는 .png 요청에도 JPEG 를 돌려준다(2026-09-16 실측 · 잘못된 MIME 이면 satori 가 배경을 조용히 버림) */
|
|
10
|
+
export declare function sniffMime(buf: Buffer): string;
|
|
11
|
+
declare const dataUri: (file: string) => string;
|
|
12
|
+
export { dataUri };
|
|
9
13
|
type El = {
|
|
10
14
|
type: string;
|
|
11
15
|
props: Record<string, unknown>;
|
|
@@ -22,4 +26,8 @@ export declare function renderConcept(dir: string, brief: Brief, concept: Concep
|
|
|
22
26
|
}): Promise<CreativeAsset[]>;
|
|
23
27
|
/** 구글 RDA 로고 자산 — 사이트 로고가 있으면 그것을, 없으면 회사명 텍스트 로고를 1:1(512)·4:1(1200×300)로 만든다 */
|
|
24
28
|
export declare function renderLogo(dir: string, brief: Brief): Promise<CreativeAsset[]>;
|
|
25
|
-
|
|
29
|
+
/** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
|
|
30
|
+
export declare function renderOverlay(file: string, w: number, hh: number, text: string, o: {
|
|
31
|
+
kind: 'line' | 'cta';
|
|
32
|
+
primary: string;
|
|
33
|
+
}): Promise<string>;
|
|
@@ -28,7 +28,14 @@ export async function loadFonts() {
|
|
|
28
28
|
}
|
|
29
29
|
return out;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
/** 🔴 확장자가 아니라 바이트로 MIME 판별 — Gemini 이미지 API 는 .png 요청에도 JPEG 를 돌려준다(2026-09-16 실측 · 잘못된 MIME 이면 satori 가 배경을 조용히 버림) */
|
|
32
|
+
export function sniffMime(buf) { if (buf[0] === 0x89 && buf[1] === 0x50)
|
|
33
|
+
return 'image/png'; if (buf[0] === 0xff && buf[1] === 0xd8)
|
|
34
|
+
return 'image/jpeg'; if (buf.subarray(0, 4).toString() === 'RIFF' && buf.subarray(8, 12).toString() === 'WEBP')
|
|
35
|
+
return 'image/webp'; if (/^\s*<(\?xml|svg)/.test(buf.subarray(0, 100).toString()))
|
|
36
|
+
return 'image/svg+xml'; return 'image/png'; }
|
|
37
|
+
const dataUri = (file) => { const buf = fs.readFileSync(file); return `data:${sniffMime(buf)};base64,${buf.toString('base64')}`; };
|
|
38
|
+
export { dataUri };
|
|
32
39
|
async function fetchLogo(url, dir) {
|
|
33
40
|
if (!url)
|
|
34
41
|
return null;
|
|
@@ -155,3 +162,16 @@ export async function renderLogo(dir, brief) {
|
|
|
155
162
|
}
|
|
156
163
|
return out;
|
|
157
164
|
}
|
|
165
|
+
/** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
|
|
166
|
+
export async function renderOverlay(file, w, hh, text, o) {
|
|
167
|
+
const fonts = await loadFonts();
|
|
168
|
+
const base = Math.min(w, hh);
|
|
169
|
+
const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
|
|
170
|
+
const pill = o.kind === 'cta'
|
|
171
|
+
? 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
|
+
: 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]);
|
|
174
|
+
const svg = await satori(el, { width: w, height: hh, fonts });
|
|
175
|
+
fs.writeFileSync(file, new Resvg(svg, { fitTo: { mode: 'width', value: w } }).render().asPng());
|
|
176
|
+
return file;
|
|
177
|
+
}
|
|
@@ -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>;
|