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.
Files changed (37) hide show
  1. package/dist/adapters/meta.d.ts +2 -0
  2. package/dist/adapters/meta.js +121 -44
  3. package/dist/adapters/mock.js +24 -10
  4. package/dist/adapters/types.d.ts +30 -0
  5. package/dist/adapters/types.js +16 -0
  6. package/dist/cli.js +2 -2
  7. package/dist/core/brief.js +12 -3
  8. package/dist/core/creatives/copy.d.ts +12 -3
  9. package/dist/core/creatives/copy.js +27 -6
  10. package/dist/core/creatives/factory.d.ts +29 -0
  11. package/dist/core/creatives/factory.js +183 -0
  12. package/dist/core/creatives/images.d.ts +31 -2
  13. package/dist/core/creatives/images.js +126 -57
  14. package/dist/core/creatives/media.d.ts +118 -0
  15. package/dist/core/creatives/media.js +91 -0
  16. package/dist/core/creatives/playbook.d.ts +40 -0
  17. package/dist/core/creatives/playbook.js +61 -0
  18. package/dist/core/creatives/render.d.ts +23 -2
  19. package/dist/core/creatives/render.js +80 -5
  20. package/dist/core/creatives/siteshots.d.ts +9 -0
  21. package/dist/core/creatives/siteshots.js +53 -0
  22. package/dist/core/creatives/storyboard.d.ts +70 -0
  23. package/dist/core/creatives/storyboard.js +149 -0
  24. package/dist/core/creatives/video.d.ts +35 -0
  25. package/dist/core/creatives/video.js +177 -0
  26. package/dist/core/creatives/videofx.d.ts +59 -0
  27. package/dist/core/creatives/videofx.js +125 -0
  28. package/dist/core/llm.js +9 -2
  29. package/dist/core/ops.d.ts +6 -0
  30. package/dist/core/ops.js +38 -6
  31. package/dist/core/site.d.ts +2 -0
  32. package/dist/core/site.js +5 -1
  33. package/dist/core/state.d.ts +43 -1
  34. package/dist/index.d.ts +7 -0
  35. package/dist/index.js +7 -0
  36. package/dist/mcp.js +2 -2
  37. package/package.json +1 -1
@@ -0,0 +1,183 @@
1
+ // 소재 공장 v2 — 컨셉 × 포맷(콘티) 영상을 끝까지 만든다.
2
+ // 콘티(storyboard.ts · 훅→증명→행동 · 채점) → 클립 생성(Veo/Seedance · UGC 는 인물 발화 · 시네마틱은 4+4 컷 · ui_demo 는 실제 사이트 화면 스크롤) → 이어붙이기
3
+ // → 언어별 자막(상단 훅 + 하단 자막 + CTA · 안전영역) → 언어별 엔드카드 → 4:5·1:1 파생(파생 뒤 자막을 다시 얹어 위치가 어긋나지 않게).
4
+ // CLI(ops.ts)와 웹(pipeline.ts)이 같은 함수를 부른다. 원가는 onAsset 으로 건별 통보(청구는 호출자 · ×1.1).
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { DERIVED_RATIOS, VIDEO_DIMS } from './media.js';
8
+ import { genreStyle, TONE_WORDS } from './playbook.js';
9
+ import { ctaLabel, renderConcept, renderOverlay, renderPhoneStage } from './render.js';
10
+ import { langName, localizedBoard, localizedConcept, planStoryboards, translateCopy } from './storyboard.js';
11
+ import { generateVideo } from './video.js';
12
+ import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, probe, uiDemoClip } from './videofx.js';
13
+ /** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
14
+ export async function renderEndCard(dir, brief, concept, ratio, lang, backgrounds = {}) {
15
+ try {
16
+ const { w, h } = VIDEO_DIMS[ratio];
17
+ const sub = path.join(dir, `end_${lang}`);
18
+ fs.mkdirSync(sub, { recursive: true });
19
+ // 엔드카드 배경 = 그 비율의 생성 배경(브랜드 그래디언트만 남지 않게)
20
+ const out = await renderConcept(sub, { ...brief, language: lang }, concept, backgrounds, { force: false, only: [{ w, h, ratio, medium: 'meta', kind: 'poster' }] });
21
+ return out[0]?.file || null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /** 자막 오버레이 묶음 — 훅(0~2.4초 · 상단) → 자막 줄(균등) → CTA(마지막 구간) */
28
+ async function overlaysFor(dir, key, dims, text, cta, primary, durationSec, lang) {
29
+ const out = [];
30
+ const hookEnd = Math.min(2.4, durationSec * 0.3);
31
+ if (text.hook)
32
+ out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_hook.png`), dims.w, dims.h, text.hook, { kind: 'hook', primary, lang }), start: 0, end: hookEnd });
33
+ const lines = text.captions.filter(Boolean).slice(0, 3);
34
+ if (lines.length) {
35
+ const from = text.hook ? hookEnd - 0.2 : 0;
36
+ const seg = (durationSec - from) / lines.length;
37
+ lines.forEach((t, i) => { void t; });
38
+ for (const [i, t] of lines.entries())
39
+ out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_cap${i}.png`), dims.w, dims.h, t, { kind: 'line', primary, lang }), start: from + i * seg, end: from + (i + 1) * seg - (i === lines.length - 1 ? 0 : 0.1) });
40
+ }
41
+ out.push({ png: await renderOverlay(path.join(dir, `${key}_${lang}_${dims.w}x${dims.h}_cta.png`), dims.w, dims.h, cta, { kind: 'cta', primary, lang }), start: Math.max(0, durationSec - Math.min(3, durationSec / 3)), end: durationSec + 1 });
42
+ return out;
43
+ }
44
+ /** 컨셉의 영상 소재 전부(포맷 × 언어 × 비율) — 실패한 포맷은 건너뛰고 로그 · 성공한 건마다 onAsset */
45
+ export async function produceConceptVideos(o) {
46
+ const log = o.log || (() => { });
47
+ const c = o.concept;
48
+ const out = [];
49
+ const ff = await hasFfmpeg();
50
+ if (!ff)
51
+ log(' ⚠ ffmpeg 가 없어 자막·엔드카드·비율 파생 없이 원본 영상만 써요');
52
+ const tone = o.tone || 'lively';
53
+ const tw = TONE_WORDS[tone];
54
+ const baseLang = o.brief.language || 'ko';
55
+ // 시장 언어만 만든다(브리프 언어가 시장에 없으면 번역 원문으로만 쓰고 그 언어 영상은 만들지 않음)
56
+ const langs = o.langs?.length ? [...new Set(o.langs)] : [baseLang];
57
+ const hasScreens = !!o.siteShot || !!(o.siteImages && o.siteImages.length);
58
+ const formats = [...new Set(o.jobs.map((j) => j.format || 'cinematic'))];
59
+ // 1) 콘티 — 컨셉에 없거나 포맷이 바뀌었으면 새로 쓰고 컨셉에 붙인다(호출자가 저장)
60
+ if (!c.storyboards?.length || formats.some((f) => !c.storyboards.some((b) => b.format === f))) {
61
+ log(`「${c.name}」 콘티 쓰는 중… (포맷 ${formats.join('·')} · 톤 ${tone})`);
62
+ c.storyboards = await planStoryboards(o.brief, c, { formats, durationSec: o.jobs[0]?.durationSec || 8, tone, goal: o.goal || 'traffic', hasScreens, log });
63
+ }
64
+ // 2) 시장 언어 번역(문구·자막·훅·대사)
65
+ const extra = langs.filter((l) => l.split('-')[0] !== baseLang.split('-')[0]);
66
+ if (extra.length && (!c.i18n || extra.some((l) => !c.i18n[l]))) {
67
+ log(` ${extra.map(langName).join('·')} 로 문구·자막 옮기는 중…`);
68
+ c.i18n = { ...(c.i18n || {}), ...(await translateCopy(o.brief, c, c.storyboards, extra)) };
69
+ }
70
+ for (const job of o.jobs) {
71
+ const format = (job.format || 'cinematic');
72
+ const board = c.storyboards.find((b) => b.format === format) || c.storyboards[0];
73
+ const dims = VIDEO_DIMS[job.ratio];
74
+ const key = `${c.key}_${format}`;
75
+ const rawFile = path.join(o.dir, `${key}_${job.ratio}.raw.mp4`);
76
+ let costKrw = 0;
77
+ if (!fs.existsSync(rawFile)) {
78
+ log(`「${c.name}」 ${format} 영상 ${job.ratio.replace('x', ':')} ${job.durationSec}초 — 훅 「${board.hook}」 (${job.provider === 'veo' ? 'Veo 3.1' : 'Seedance 2.5'} · 장르 ${board.genre})`);
79
+ const clipFiles = [];
80
+ let failed = false;
81
+ for (const [ci, clip] of board.clips.entries()) {
82
+ const cf = path.join(o.dir, `${key}_${job.ratio}_c${ci}.mp4`);
83
+ try {
84
+ if (clip.kind === 'ui_demo') {
85
+ if (!ff)
86
+ throw new Error('ffmpeg 없음');
87
+ const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
88
+ if (o.siteShot)
89
+ await uiDemoClip(o.siteShot, cf, { sec: clip.sec, stage, dims });
90
+ else if (o.siteImages?.[0])
91
+ await kenBurnsClip(o.siteImages[0], cf, { sec: clip.sec, dims });
92
+ else
93
+ throw new Error('사이트 화면이 없어요');
94
+ log(` 화면 데모 컷 ${clip.sec}초 ✓ (생성 비용 0)`);
95
+ }
96
+ else {
97
+ const person = format === 'ugc_selfie';
98
+ const speech = clip.speech ? ` The person speaks to camera in ${langName(board.lang)}: "${clip.speech}". Natural lip sync, conversational, genuine.` : '';
99
+ const prompt = `${clip.prompt}${speech} Style: ${genreStyle(board.genre)}. Camera: ${tw.camera}. Audio: ${job.audio ? `${board.music || tw.music}${person ? ', clear voice over the music' : ''}` : 'silent'}. No on-screen text, no subtitles, no captions, no logos, no watermark.`;
100
+ const firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
101
+ const r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: !firstFrame && !person ? o.refs?.slice(0, 3) : undefined, negative: 'text, subtitles, captions, letters, watermark, logo, blurry, distorted face, extra fingers', log });
102
+ costKrw += r.costKrw;
103
+ log(` 컷 ${ci + 1}/${board.clips.length} ${clip.sec}초 ✓ 원가 ₩${Math.round(r.costKrw).toLocaleString()}`);
104
+ }
105
+ clipFiles.push(cf);
106
+ }
107
+ catch (e) {
108
+ const ck = Number(e.costKrw || 0);
109
+ costKrw += ck;
110
+ log(` ❌ ${format} 컷 ${ci + 1} 실패 — ${e instanceof Error ? e.message.slice(0, 200) : e}${ck ? ` (원가 ₩${ck} 발생)` : ''}`);
111
+ if (clip.kind === 'ui_demo' && clipFiles.length)
112
+ continue; // 화면 컷만 빠지면 생성 컷으로 진행
113
+ failed = true;
114
+ break;
115
+ }
116
+ }
117
+ if (failed || !clipFiles.length) {
118
+ if (costKrw > 0)
119
+ await o.onAsset?.({ concept: c.key, w: 0, h: 0, ratio: job.ratio, file: '', medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw, origin: 'ai', format });
120
+ continue;
121
+ }
122
+ try {
123
+ if (ff)
124
+ await concatClips(clipFiles, rawFile, dims);
125
+ else
126
+ fs.copyFileSync(clipFiles[0], rawFile);
127
+ }
128
+ catch (e) {
129
+ log(` ⚠ 이어붙이기 실패(첫 컷만 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
130
+ fs.copyFileSync(clipFiles[0], rawFile);
131
+ }
132
+ }
133
+ // 3) 언어 × 비율 마감 — 원본(raw)에서 비율을 먼저 만들고 그 위에 자막·엔드카드
134
+ const ratios = [job.ratio, ...(ff ? DERIVED_RATIOS[job.ratio].filter((r) => !o.jobs.some((j) => j.ratio === r)) : [])];
135
+ for (const lang of langs) {
136
+ const lb = localizedBoard(board, c.i18n, lang);
137
+ const lc = localizedConcept(c, c.i18n, lang);
138
+ const cta = ctaLabel(c.cta, lang);
139
+ for (const ratio of ratios) {
140
+ const d = VIDEO_DIMS[ratio];
141
+ const finalFile = path.join(o.dir, `${key}_${lang}_${ratio}.mp4`);
142
+ let cur = rawFile;
143
+ let durationSec = job.durationSec;
144
+ if (ff) {
145
+ try {
146
+ const pr0 = await probe(rawFile);
147
+ durationSec = pr0.durationSec;
148
+ if (ratio !== job.ratio) {
149
+ const df = path.join(o.dir, `${key}_${ratio}.raw.mp4`);
150
+ if (!fs.existsSync(df))
151
+ await deriveRatio(rawFile, df, ratio);
152
+ cur = df;
153
+ }
154
+ if (job.captions) {
155
+ const withCap = path.join(o.dir, `${key}_${lang}_${ratio}.cap.mp4`);
156
+ await burnCaptions(cur, withCap, { overlays: await overlaysFor(o.dir, key, d, lb, cta, o.brief.palette.primary, durationSec, lang) });
157
+ cur = withCap;
158
+ }
159
+ if (job.endCard) {
160
+ const card = await renderEndCard(o.dir, o.brief, lc, ratio, lang, o.backgrounds);
161
+ if (card) {
162
+ const withEnd = path.join(o.dir, `${key}_${lang}_${ratio}.end.mp4`);
163
+ await appendEndCard(cur, card, withEnd, 2);
164
+ cur = withEnd;
165
+ durationSec += 2;
166
+ }
167
+ }
168
+ }
169
+ catch (e) {
170
+ log(` ⚠ ${lang} ${ratio} 마감 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 160) : e}`);
171
+ }
172
+ }
173
+ fs.copyFileSync(cur, finalFile);
174
+ const primary = lang === langs[0] && ratio === job.ratio;
175
+ const asset = { concept: c.key, w: d.w, h: d.h, ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: primary ? costKrw : 0, origin: 'ai', format, lang, variant: `${format}_${lang}` };
176
+ out.push(asset);
177
+ await o.onAsset?.(asset);
178
+ }
179
+ }
180
+ log(` ${format} 완성 — 언어 ${langs.join('·')} × 비율 ${ratios.map((r) => r.replace('x', ':')).join('·')}${costKrw ? ` · 생성 원가 ₩${Math.round(costKrw).toLocaleString()}` : ''}`);
181
+ }
182
+ return out;
183
+ }
@@ -1,9 +1,38 @@
1
+ export type ImageProvider = 'bizrouter' | 'openai';
1
2
  export declare function imageConfig(): {
2
3
  key: string;
3
4
  model: string;
5
+ base: string;
6
+ provider: ImageProvider;
4
7
  } | null;
5
8
  export declare function imagesAvailable(): boolean;
6
- /** 컨셉 × 비율 배경 생성 → 파일 경로. 이미 있으면 건너뜀(재실행 안전). */
7
- export declare function generateBackgrounds(dir: string, conceptKey: string, prompt: string, ratios?: string[], log?: (s: string) => void): Promise<Record<string, string | null>>;
9
+ export declare const isMockGen: () => boolean;
10
+ export type ImageResult = {
11
+ file: string;
12
+ costKrw: number;
13
+ model: string;
14
+ };
15
+ /** 이미지 한 장 — 파일로 저장 · 원가(원) 반환. 실패는 예외(fatal 이면 재시도 없이) */
16
+ export declare function generateImage(opts: {
17
+ file: string;
18
+ prompt: string;
19
+ ratio: string;
20
+ model?: string;
21
+ refs?: string[];
22
+ log?: (s: string) => void;
23
+ quality?: 'low' | 'medium' | 'high';
24
+ }): Promise<ImageResult>;
25
+ /** 컨셉 × 비율 배경 생성 → 파일 경로. 이미 있으면 건너뜀(재실행 안전). 원가는 onAsset 으로 알린다. */
26
+ export declare function generateBackgrounds(dir: string, conceptKey: string, prompt: string, ratios?: string[], log?: (s: string) => void, opts?: {
27
+ model?: string;
28
+ refs?: string[];
29
+ variant?: string;
30
+ onAsset?: (a: {
31
+ ratio: string;
32
+ file: string;
33
+ costKrw: number;
34
+ model: string;
35
+ }) => void | Promise<void>;
36
+ }): Promise<Record<string, string | null>>;
8
37
  /** 사용자 이미지 폴더 → 비율별 배경 매핑(가장 가까운 비율) */
9
38
  export declare function userBackgrounds(folder: string, conceptKey: string): Record<string, string | null>;
@@ -1,79 +1,148 @@
1
- // 배경 이미지 — OpenAI Images(gpt-image)로 비율 4종 생성. 키가 없으면 null → 렌더러가 브랜드 그래디언트로 대신한다. 사용자가 준 이미지(--images 폴더)도 그대로 배경으로 쓴다.
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 key = process.env.ADPILOT_IMAGE_KEY || process.env.OPENAI_API_KEY || c.apiKey;
9
- if (!key)
10
- return null;
11
- return { key, model: process.env.ADPILOT_IMAGE_MODEL || c.model || 'gpt-image-2.5-flare' };
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
- export async function generateBackgrounds(dir, conceptKey, prompt, ratios = Object.keys(BG_RATIOS), log = () => { }) {
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 (!cfg) {
132
+ if (!available) {
26
133
  out[r] = null;
27
134
  return;
28
135
  }
29
- let size = BG_RATIOS[r].openai;
30
- let model = cfg.model;
31
- const FALLBACK = ['gpt-image-2.5-flare', 'gpt-image-2', 'gpt-image-1.5', 'gpt-image-1'];
32
- for (let attempt = 0; attempt < 5; attempt++) {
33
- try {
34
- const res = await fetch('https://api.openai.com/v1/images/generations', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model, prompt: `${prompt} ${STYLE}`, size, quality: 'high', n: 1 }), signal: AbortSignal.timeout(300_000) });
35
- if (!res.ok) {
36
- const t = await res.text();
37
- if (res.status === 400 && /does not exist|model/i.test(t) && /model/i.test(t) && !/size/i.test(t)) {
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,118 @@
1
+ import type { Medium } from '../../adapters/types.js';
2
+ import { type Tone, type VideoFormat } from './playbook.js';
3
+ export type CreativeMode = 'auto' | 'image' | 'video' | 'text';
4
+ export type CreativeSource = 'ai' | 'guided' | 'manual';
5
+ export type Tier = 'economy' | 'standard' | 'premium';
6
+ export type VideoRatio = '9x16' | '16x9' | '1x1' | '4x5';
7
+ export type VideoProvider = 'auto' | 'veo' | 'seedance';
8
+ export type VideoDuration = 8 | 15 | 30;
9
+ export type CreativeSpec = {
10
+ mode?: CreativeMode;
11
+ source?: CreativeSource;
12
+ /** guided: 요구사항·방향(자연어). manual 에서도 참고용으로 남는다 */
13
+ prompt?: string;
14
+ /** 컨셉(방향) 수 1~5 — 비우면 모드별 기본 */
15
+ concepts?: number;
16
+ images?: {
17
+ tier?: Tier;
18
+ variants?: number;
19
+ };
20
+ /** 톤 다이얼(차분/활기/강하게) — 비우면 업종 플레이북 기본 */
21
+ tone?: Tone;
22
+ video?: {
23
+ count?: number; /** 포맷 지정(비우면 플레이북·장르·화면 유무로 자동) */
24
+ formats?: VideoFormat[];
25
+ durationSec?: VideoDuration;
26
+ ratios?: VideoRatio[];
27
+ audio?: boolean;
28
+ tier?: Tier;
29
+ captions?: boolean;
30
+ endCard?: boolean;
31
+ provider?: VideoProvider;
32
+ resolution?: '720p' | '1080p';
33
+ };
34
+ /** manual: 광고주가 직접 올린 문구 */
35
+ copy?: {
36
+ headlines?: string[];
37
+ bodies?: string[];
38
+ descriptions?: string[];
39
+ };
40
+ };
41
+ export type VideoJob = {
42
+ ratio: VideoRatio;
43
+ durationSec: number;
44
+ provider: 'veo' | 'seedance';
45
+ model: string;
46
+ resolution: '720p' | '1080p';
47
+ audio: boolean;
48
+ captions: boolean;
49
+ endCard: boolean;
50
+ unitKrw: number; /** 콘티 포맷(영상 1편 = 포맷 1개) */
51
+ format?: VideoFormat;
52
+ };
53
+ export type CreativePlan = {
54
+ mode: CreativeMode;
55
+ source: CreativeSource;
56
+ concepts: number;
57
+ tone: Tone;
58
+ formats: VideoFormat[];
59
+ images: {
60
+ enabled: boolean;
61
+ tier: Tier;
62
+ model: string;
63
+ variants: number;
64
+ ratios: string[];
65
+ unitKrw: number;
66
+ };
67
+ videos: {
68
+ perConcept: VideoJob[];
69
+ conceptsWithVideo: number;
70
+ };
71
+ estimate: {
72
+ imagesKrw: number;
73
+ videosKrw: number;
74
+ totalKrw: number;
75
+ chargedKrw: number;
76
+ lines: string[];
77
+ };
78
+ explain: string;
79
+ };
80
+ /** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
81
+ export declare const CREATIVE_MARKUP = 1.1;
82
+ export declare const chargeFor: (costKrw: number) => number;
83
+ /** 달러 → 원. BizRouter 모델표가 쓰는 환율(1436)과 맞춘다 · 환경변수로 덮어쓸 수 있다 */
84
+ export declare const USD_KRW: () => number;
85
+ export declare const IMAGE_MODELS: Record<Tier, {
86
+ model: string;
87
+ unitKrw: number;
88
+ }>;
89
+ /** 영상 초당 원가(원) — 8초 이하는 Veo(BizRouter 한 키) · 15/30초는 Seedance 2.5(ARK) */
90
+ export declare const VIDEO_MODELS: Record<string, {
91
+ provider: 'veo' | 'seedance';
92
+ perSecKrw: Record<'720p' | '1080p', number>;
93
+ maxSec: number;
94
+ ratios: VideoRatio[];
95
+ }>;
96
+ export declare function pickVideoModel(durationSec: number, tier: Tier, provider?: VideoProvider, ratio?: VideoRatio): string;
97
+ export declare const MODE_LABEL: Record<CreativeMode, string>;
98
+ export declare const SOURCE_LABEL: Record<CreativeSource, string>;
99
+ /**
100
+ * 설정 + 예산 → 실행 계획. 온라인 광고 소재의 일반 배합:
101
+ * - 이미지는 컨셉마다 비율 4종(1:1·4:5·9:16·16:9) 배경을 만들어 12규격으로 합성(기존) · 「이미지 위주」는 배경 2세트(A/B)
102
+ * - 영상은 9:16(릴스·스토리·쇼츠) 8초가 기본 · 「영상 위주」는 16:9(피드·유튜브) 추가 · 1:1/4:5 는 9:16 에서 무료로 잘라 만든다
103
+ * - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
104
+ * - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
105
+ */
106
+ export declare function resolveCreativePlan(spec: CreativeSpec | undefined, ctx: {
107
+ monthlyKrw: number;
108
+ media: Medium[];
109
+ industry?: string | null;
110
+ genres?: string[];
111
+ hasScreens?: boolean;
112
+ }): CreativePlan;
113
+ /** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
114
+ export declare const DERIVED_RATIOS: Record<VideoRatio, VideoRatio[]>;
115
+ export declare const VIDEO_DIMS: Record<VideoRatio, {
116
+ w: number;
117
+ h: number;
118
+ }>;
@@ -0,0 +1,91 @@
1
+ import { pickFormats, playbookFor } from './playbook.js';
2
+ /** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
3
+ export const CREATIVE_MARKUP = 1.1;
4
+ export const chargeFor = (costKrw) => Math.ceil(Math.max(0, costKrw) * CREATIVE_MARKUP);
5
+ /** 달러 → 원. BizRouter 모델표가 쓰는 환율(1436)과 맞춘다 · 환경변수로 덮어쓸 수 있다 */
6
+ export const USD_KRW = () => Number(process.env.USD_KRW || 1436);
7
+ export const IMAGE_MODELS = {
8
+ economy: { model: 'google/gemini-3.1-flash-lite-image', unitKrw: 55 },
9
+ standard: { model: 'google/gemini-3.1-flash-image', unitKrw: 105 },
10
+ premium: { model: 'google/gemini-3-pro-image', unitKrw: 240 },
11
+ };
12
+ /** 영상 초당 원가(원) — 8초 이하는 Veo(BizRouter 한 키) · 15/30초는 Seedance 2.5(ARK) */
13
+ export const VIDEO_MODELS = {
14
+ 'google/veo-3.1-lite': { provider: 'veo', perSecKrw: { '720p': 72, '1080p': 115 }, maxSec: 8, ratios: ['9x16', '16x9'] },
15
+ 'google/veo-3.1-fast': { provider: 'veo', perSecKrw: { '720p': 144, '1080p': 173 }, maxSec: 8, ratios: ['9x16', '16x9'] },
16
+ 'google/veo-3.1': { provider: 'veo', perSecKrw: { '720p': 575, '1080p': 575 }, maxSec: 8, ratios: ['9x16', '16x9'] },
17
+ 'dreamina-seedance-2-5-260628': { provider: 'seedance', perSecKrw: { '720p': 335, '1080p': 820 }, maxSec: 30, ratios: ['9x16', '16x9', '1x1', '4x5'] },
18
+ };
19
+ const VEO_BY_TIER = { economy: 'google/veo-3.1-lite', standard: 'google/veo-3.1-fast', premium: 'google/veo-3.1' };
20
+ export function pickVideoModel(durationSec, tier, provider = 'auto', ratio = '9x16') {
21
+ if (provider === 'seedance')
22
+ return 'dreamina-seedance-2-5-260628';
23
+ if (provider === 'veo')
24
+ return VEO_BY_TIER[tier];
25
+ // auto: 8초·9:16/16:9 는 Veo(싸고 한 키) · 그 외(15/30초 · 1:1·4:5 원본)는 Seedance
26
+ if (durationSec <= 8 && (ratio === '9x16' || ratio === '16x9'))
27
+ return VEO_BY_TIER[tier];
28
+ return 'dreamina-seedance-2-5-260628';
29
+ }
30
+ const FORMAT_LABEL_SHORT = { ugc_selfie: '셀카 후기', ui_demo: '화면 데모', cinematic: '시네마틱', product_hero: '제품', lifestyle: '라이프스타일', text_hook: '글자 훅' };
31
+ export const MODE_LABEL = { auto: '알아서(추천)', image: '이미지 위주', video: '영상 위주', text: '글·링크 위주' };
32
+ export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구사항·참고 이미지로', manual: '직접 올린 소재로' };
33
+ /**
34
+ * 설정 + 예산 → 실행 계획. 온라인 광고 소재의 일반 배합:
35
+ * - 이미지는 컨셉마다 비율 4종(1:1·4:5·9:16·16:9) 배경을 만들어 12규격으로 합성(기존) · 「이미지 위주」는 배경 2세트(A/B)
36
+ * - 영상은 9:16(릴스·스토리·쇼츠) 8초가 기본 · 「영상 위주」는 16:9(피드·유튜브) 추가 · 1:1/4:5 는 9:16 에서 무료로 잘라 만든다
37
+ * - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
38
+ * - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
39
+ */
40
+ export function resolveCreativePlan(spec, ctx) {
41
+ const s = spec || {};
42
+ const mode = s.mode || 'auto';
43
+ const source = s.source || 'ai';
44
+ const small = ctx.monthlyKrw < 500_000;
45
+ const concepts = Math.max(1, Math.min(5, s.concepts || (source === 'manual' ? 1 : mode === 'text' ? 4 : 3)));
46
+ const imgTier = s.images?.tier || (ctx.monthlyKrw >= 3_000_000 ? 'standard' : 'economy');
47
+ const imagesEnabled = source !== 'manual' && mode !== 'text';
48
+ const variants = Math.max(1, Math.min(3, s.images?.variants || (mode === 'image' ? 2 : 1)));
49
+ const ratios = ['1x1', '4x5', '9x16', '16x9'];
50
+ const vTier = s.video?.tier || (ctx.monthlyKrw >= 2_000_000 ? 'standard' : 'economy');
51
+ const duration = s.video?.durationSec || 8;
52
+ const wantVideo = source !== 'manual' && s.video?.count !== 0 && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0) && mode !== 'text' && mode !== 'image';
53
+ const vRatios = s.video?.ratios?.length ? s.video.ratios : ['9x16'];
54
+ const pb = playbookFor(ctx.industry);
55
+ const tone = s.tone || pb.tone;
56
+ // 영상 1편 = 콘티 포맷 1개 — 「알아서」는 2포맷(예 셀카 후기 + 장르 시네마틱) · 「영상 위주」는 3포맷. 매체가 포맷 성과를 비교한다.
57
+ const fmtCount = s.video?.count ?? (mode === 'video' ? 3 : 2);
58
+ const formats = wantVideo ? pickFormats({ industry: ctx.industry, genres: ctx.genres, hasScreens: ctx.hasScreens ?? true, count: Math.max(1, Math.min(4, fmtCount)), requested: s.video?.formats }) : [];
59
+ const count = wantVideo ? formats.length : 0;
60
+ const resolution = s.video?.resolution || '720p';
61
+ const perConcept = [];
62
+ for (let i = 0; i < count; i++) {
63
+ const ratio = vRatios[i % vRatios.length];
64
+ const model = pickVideoModel(duration, vTier, s.video?.provider || 'auto', ratio);
65
+ const m = VIDEO_MODELS[model];
66
+ const sec = Math.min(duration, m.maxSec);
67
+ // ui_demo 는 8초 중 4초만 생성(나머지는 실제 화면 · 무료)
68
+ const genSec = formats[i] === 'ui_demo' && sec <= 8 && (ctx.hasScreens ?? true) ? Math.round(sec / 2) : sec;
69
+ 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] * genSec), format: formats[i] });
70
+ }
71
+ const conceptsWithVideo = count ? (small && mode !== 'video' ? 1 : concepts) : 0;
72
+ const img = IMAGE_MODELS[imgTier];
73
+ const imagesKrw = imagesEnabled ? concepts * variants * ratios.length * img.unitKrw : 0;
74
+ const videosKrw = conceptsWithVideo * perConcept.reduce((n, v) => n + v.unitKrw, 0);
75
+ const totalKrw = imagesKrw + videosKrw;
76
+ const lines = [];
77
+ if (imagesEnabled)
78
+ lines.push(`이미지 배경 ${concepts * variants * ratios.length}장(방향 ${concepts} × 세트 ${variants} × 비율 4) ≈ ₩${imagesKrw.toLocaleString()} → 12가지 크기로 합성(합성은 무료)`);
79
+ else if (source === 'manual')
80
+ lines.push('이미지·영상은 올려 주신 것을 그대로 써요(생성 비용 0)');
81
+ else
82
+ lines.push('이미지 생성 없이 브랜드 색 배경 + 문구로 만들어요(비용 0)');
83
+ if (conceptsWithVideo)
84
+ lines.push(`영상 ${conceptsWithVideo * perConcept.length}편(방향 ${conceptsWithVideo} × 포맷 ${perConcept.map((v) => `${FORMAT_LABEL_SHORT[v.format || 'cinematic']} ${v.durationSec}초`).join('·')}) ≈ ₩${videosKrw.toLocaleString()}${perConcept.some((v) => v.audio) ? ' · 소리 포함' : ''} · 4:5·1:1 파생 무료`);
85
+ lines.push(`예상 원가 ₩${totalKrw.toLocaleString()} → 청구 ₩${chargeFor(totalKrw).toLocaleString()}(원가의 1.1배 · 실제 생성된 것만, 만들어진 뒤 정확한 원가로 차감)`);
86
+ const explain = [`소재 형태: ${MODE_LABEL[mode]} · 출처: ${SOURCE_LABEL[source]} · 톤: ${tone} · 업종 플레이북: ${pb.label}`, ...lines].join('\n');
87
+ return { mode, source, concepts, tone, formats, 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 };
88
+ }
89
+ /** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
90
+ export const DERIVED_RATIOS = { '9x16': ['4x5', '1x1'], '16x9': ['1x1'], '1x1': [], '4x5': [] };
91
+ export const VIDEO_DIMS = { '9x16': { w: 1080, h: 1920 }, '16x9': { w: 1920, h: 1080 }, '1x1': { w: 1080, h: 1080 }, '4x5': { w: 1080, h: 1350 } };