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.
@@ -0,0 +1,177 @@
1
+ // 영상 생성 — 두 공급자.
2
+ // veo: BizRouter /v1/video/generation (google/veo-3.1[-lite|-fast]) · 4/6/8초 · 16:9|9:16 · 첫 프레임 이미지(image-to-video) · 참조 이미지 ≤3(8초) · 접수 시 cost_krw 확정 · 완료 뒤 30분 안에 다운로드
3
+ // seedance: BytePlus ModelArk(dreamina-seedance-2-5-260628) · 4~30초 · 480p/720p/1080p · 9:16·16:9·1:1·4:3·3:4 · content[] 에 text + image_url(role first_frame|reference_image) · 원가 = completion_tokens × $10.7/M × FX
4
+ // 두 공급자 모두 네이티브 소리(BGM·효과음·대사)를 낸다 → 「소리 포함」은 generate_audio/프롬프트로.
5
+ // ADPILOT_MOCK_GEN=1 이면 ffmpeg 로 단색 영상을 만든다(e2e · API 호출 없음).
6
+ import fs from 'node:fs';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { loadConfig } from '../state.js';
10
+ import { imageConfig, isMockGen } from './images.js';
11
+ import { sniffMime } from './render.js';
12
+ import { USD_KRW, VIDEO_DIMS, VIDEO_MODELS } from './media.js';
13
+ const execFileP = promisify(execFile);
14
+ export function videoConfig() {
15
+ const c = loadConfig().video || {};
16
+ const img = imageConfig();
17
+ const veo = img && img.provider === 'bizrouter' ? { base: img.base, key: process.env.ADPILOT_VIDEO_KEY || c.apiKey || img.key } : undefined;
18
+ const ark = process.env.ARK_API_KEY || c.arkApiKey;
19
+ const seedance = ark ? { key: ark, base: (process.env.ARK_BASE_URL || 'https://ark.ap-southeast.bytepluses.com/api/v3').replace(/\/+$/, '') } : undefined;
20
+ return { veo, seedance };
21
+ }
22
+ export function videoAvailable(provider) {
23
+ if (isMockGen())
24
+ return true;
25
+ const c = videoConfig();
26
+ return provider ? !!c[provider] : !!(c.veo || c.seedance);
27
+ }
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+ const b64 = (file) => { const buf = fs.readFileSync(file); return { mime_type: sniffMime(buf), data_base64: buf.toString('base64') }; };
30
+ const dataUrl = (file) => { const m = b64(file); return `data:${m.mime_type};base64,${m.data_base64}`; };
31
+ const aspectOf = (r) => r.replace('x', ':');
32
+ async function mockVideo(i) {
33
+ const { w, h } = VIDEO_DIMS[i.ratio];
34
+ let n = 0;
35
+ for (const ch of i.prompt)
36
+ n = (n * 31 + ch.charCodeAt(0)) >>> 0;
37
+ const color = `0x${(n & 0xffffff).toString(16).padStart(6, '0')}`;
38
+ const inputs = i.firstFrame && fs.existsSync(i.firstFrame)
39
+ ? ['-loop', '1', '-t', String(i.durationSec), '-i', i.firstFrame, '-f', 'lavfi', '-t', String(i.durationSec), '-i', 'anullsrc=r=44100:cl=stereo', '-vf', `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},format=yuv420p`]
40
+ : ['-f', 'lavfi', '-t', String(i.durationSec), '-i', `color=c=${color}:s=${w}x${h}:r=24`, '-f', 'lavfi', '-t', String(i.durationSec), '-i', 'anullsrc=r=44100:cl=stereo', '-vf', 'format=yuv420p'];
41
+ await execFileP('ffmpeg', ['-y', '-v', 'error', ...inputs, '-c:v', 'libx264', '-preset', 'veryfast', '-c:a', 'aac', '-shortest', '-movflags', '+faststart', i.file]);
42
+ return { file: i.file, costKrw: i.durationSec * 100, provider: 'mock', model: 'mock', durationSec: i.durationSec };
43
+ }
44
+ /** Veo(BizRouter) — 접수 → 폴링 → 다운로드 */
45
+ async function veo(i, cfg) {
46
+ const log = i.log || (() => { });
47
+ const H = { authorization: `Bearer ${cfg.key}` };
48
+ const dur = i.durationSec <= 4 ? 4 : i.durationSec <= 6 ? 6 : 8;
49
+ const resolution = dur < 8 ? '720p' : i.resolution || '720p';
50
+ const ratio = i.ratio === '16x9' ? '16x9' : '9x16'; // 1:1·4:5 는 9:16 에서 파생
51
+ // 🔴 veo-3.1-lite 는 negativePrompt 를 거절(400 · 2026-09-16 실측) → 네거티브는 프롬프트 문장으로만
52
+ const body = { model: i.model, prompt: i.negative ? `${i.prompt} Avoid: ${i.negative}.` : i.prompt, duration_seconds: dur, resolution, aspect_ratio: aspectOf(ratio) };
53
+ if (i.firstFrame && fs.existsSync(i.firstFrame))
54
+ body.image = b64(i.firstFrame);
55
+ else if (i.refs?.length && dur === 8)
56
+ body.reference_images = i.refs.filter((f) => fs.existsSync(f)).slice(0, 3).map(b64);
57
+ const res = await fetch(`${cfg.base}/v1/video/generation`, { method: 'POST', headers: { ...H, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000) });
58
+ const acc = (await res.json().catch(() => ({})));
59
+ if (!res.ok || !acc.operation_id)
60
+ throw Object.assign(new Error(`영상 접수 실패 ${res.status} ${typeof acc.error === 'string' ? acc.error : acc.error?.message || JSON.stringify(acc).slice(0, 200)}`), { fatal: res.status < 500 && res.status !== 429 });
61
+ let costKrw = acc.cost_krw || 0;
62
+ log(` 영상 접수 ${acc.operation_id} (${i.model} · ${dur}초 · ${resolution} · ${aspectOf(ratio)}${costKrw ? ` · 원가 ₩${Math.round(costKrw)}` : ''})`);
63
+ const t0 = Date.now();
64
+ while (Date.now() - t0 < 20 * 60_000) {
65
+ await sleep(8000);
66
+ const st = await fetch(`${cfg.base}/v1/video/generation/${acc.operation_id}`, { headers: H, signal: AbortSignal.timeout(60_000) });
67
+ const s = (await st.json().catch(() => ({})));
68
+ if (s.cost_krw)
69
+ costKrw = s.cost_krw;
70
+ if (s.status === 'failed')
71
+ throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw });
72
+ if (s.status === 'completed') {
73
+ const dl = await fetch(`${cfg.base}/v1/video/generation/${acc.operation_id}/download`, { headers: H, signal: AbortSignal.timeout(300_000) });
74
+ if (!dl.ok)
75
+ throw new Error(`영상 다운로드 실패 ${dl.status}`);
76
+ const tmp = i.file + '.tmp';
77
+ fs.writeFileSync(tmp, Buffer.from(await dl.arrayBuffer()));
78
+ fs.renameSync(tmp, i.file);
79
+ return { file: i.file, costKrw, provider: 'veo', model: i.model, durationSec: s.duration_seconds || dur, opId: acc.operation_id };
80
+ }
81
+ }
82
+ throw new Error('영상 생성이 20분 안에 끝나지 않았어요');
83
+ }
84
+ /** Seedance 2.5(ARK) — 태스크 생성 → 폴링 → 다운로드. 원가 = completion_tokens × $10.7/M × FX */
85
+ async function seedance(i, cfg) {
86
+ const log = i.log || (() => { });
87
+ const H = { authorization: `Bearer ${cfg.key}`, 'content-type': 'application/json' };
88
+ const content = [{ type: 'text', text: i.prompt }];
89
+ const hasFirst = !!(i.firstFrame && fs.existsSync(i.firstFrame));
90
+ if (hasFirst)
91
+ content.push({ type: 'image_url', image_url: { url: dataUrl(i.firstFrame) }, role: 'first_frame' });
92
+ for (const r of (i.refs || []).filter((f) => fs.existsSync(f)).slice(0, 4))
93
+ content.push({ type: 'image_url', image_url: { url: dataUrl(r) }, role: 'reference_image' });
94
+ const dur = Math.max(4, Math.min(30, Math.round(i.durationSec)));
95
+ const body = { model: i.model, content, resolution: i.resolution || '720p', duration: dur, ratio: hasFirst ? 'adaptive' : aspectOf(i.ratio), generate_audio: i.audio !== false, watermark: false };
96
+ const res = await fetch(`${cfg.base}/contents/generations/tasks`, { method: 'POST', headers: H, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000) });
97
+ const acc = (await res.json().catch(() => ({})));
98
+ if (!res.ok || !acc.id)
99
+ throw Object.assign(new Error(`영상 접수 실패 ${res.status} ${acc.error?.message || JSON.stringify(acc).slice(0, 200)}`), { fatal: res.status < 500 && res.status !== 429 });
100
+ log(` 영상 접수 ${acc.id} (Seedance 2.5 · ${dur}초 · ${body.resolution} · ${body.ratio})`);
101
+ const t0 = Date.now();
102
+ while (Date.now() - t0 < 40 * 60_000) {
103
+ await sleep(15_000);
104
+ const st = await fetch(`${cfg.base}/contents/generations/tasks/${acc.id}`, { headers: H, signal: AbortSignal.timeout(60_000) });
105
+ const s = (await st.json().catch(() => ({})));
106
+ const tokens = s.usage?.completion_tokens || s.usage?.total_tokens || 0;
107
+ const costKrw = Math.round((tokens / 1_000_000) * 10.7 * USD_KRW());
108
+ if (s.status === 'failed' || s.status === 'cancelled')
109
+ throw Object.assign(new Error(`영상 생성 실패: ${s.error?.message || s.status}`), { fatal: true, costKrw });
110
+ if (s.status === 'succeeded') {
111
+ const url = s.content?.video_url;
112
+ if (!url)
113
+ throw new Error('영상 주소가 비었어요');
114
+ const dl = await fetch(url, { signal: AbortSignal.timeout(300_000) });
115
+ if (!dl.ok)
116
+ throw new Error(`영상 다운로드 실패 ${dl.status}`);
117
+ const tmp = i.file + '.tmp';
118
+ fs.writeFileSync(tmp, Buffer.from(await dl.arrayBuffer()));
119
+ fs.renameSync(tmp, i.file);
120
+ return { file: i.file, costKrw, provider: 'seedance', model: i.model, durationSec: dur, opId: acc.id };
121
+ }
122
+ }
123
+ throw new Error('영상 생성이 40분 안에 끝나지 않았어요');
124
+ }
125
+ /** 영상 한 편 — 모델의 공급자로 라우팅. 실패는 예외(fatal 이면 재시도 없이 · costKrw 가 붙어 오면 그만큼은 원가 발생). */
126
+ export async function generateVideo(i) {
127
+ if (isMockGen())
128
+ return mockVideo(i);
129
+ const spec = VIDEO_MODELS[i.model];
130
+ const provider = spec?.provider || (/veo/.test(i.model) ? 'veo' : 'seedance');
131
+ const cfg = videoConfig();
132
+ let lastErr;
133
+ for (let attempt = 0; attempt < 2; attempt++) {
134
+ try {
135
+ if (provider === 'veo') {
136
+ if (!cfg.veo)
137
+ throw Object.assign(new Error('영상 생성 키(BizRouter)가 없어요'), { fatal: true });
138
+ return await veo(i, cfg.veo);
139
+ }
140
+ if (!cfg.seedance) {
141
+ if (cfg.veo) {
142
+ i.log?.(' Seedance 키가 없어 Veo 로 대신 만들어요(최대 8초)');
143
+ return await veo({ ...i, model: 'google/veo-3.1-fast', durationSec: Math.min(8, i.durationSec) }, cfg.veo);
144
+ }
145
+ throw Object.assign(new Error('영상 생성 키(ARK)가 없어요'), { fatal: true });
146
+ }
147
+ // 🔴 Seedance 「출력 오디오 저작권 의심(OutputAudioSensitiveContentDetected)」(2026-09-16 실측 · 7분 뒤 실패 · 과금 0) → 소리 지시를 바꿔 1회 → 그래도면 무음으로 1회
148
+ try {
149
+ return await seedance(i, cfg.seedance);
150
+ }
151
+ catch (e) {
152
+ const msg = e instanceof Error ? e.message : String(e);
153
+ if (!/audio may be related to copyright|OutputAudioSensitive/i.test(msg))
154
+ throw e;
155
+ i.log?.(' 소리 정책에 걸려(저작권 의심 음악) 소리 지시를 바꿔 다시 만들어요');
156
+ try {
157
+ return await seedance({ ...i, prompt: `${i.prompt} Audio: only original, generic, royalty-free instrumental ambience and light sound effects — no lyrics, no vocals, no recognizable or famous melodies.` }, cfg.seedance);
158
+ }
159
+ catch (e2) {
160
+ const m2 = e2 instanceof Error ? e2.message : String(e2);
161
+ if (!/audio may be related to copyright|OutputAudioSensitive/i.test(m2))
162
+ throw e2;
163
+ i.log?.(' 다시 걸려 이번엔 소리 없이 만들어요');
164
+ return await seedance({ ...i, audio: false, prompt: `${i.prompt} Silent video, no audio.` }, cfg.seedance);
165
+ }
166
+ }
167
+ }
168
+ catch (e) {
169
+ lastErr = e;
170
+ if (e.fatal)
171
+ throw e;
172
+ i.log?.(` 영상 생성 재시도 (${e instanceof Error ? e.message.slice(0, 120) : e})`);
173
+ await sleep(5000);
174
+ }
175
+ }
176
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
177
+ }
@@ -0,0 +1,28 @@
1
+ import { type VideoRatio } from './media.js';
2
+ export declare function hasFfmpeg(): Promise<boolean>;
3
+ export type Probe = {
4
+ w: number;
5
+ h: number;
6
+ durationSec: number;
7
+ hasAudio: boolean;
8
+ };
9
+ export declare function probe(file: string): Promise<Probe>;
10
+ export declare const ratioOf: (w: number, h: number) => VideoRatio;
11
+ /** Pretendard 굵은 글꼴 경로(render.ts 의 loadFonts 가 ~/.adpilot/fonts 에 내려받는다) */
12
+ export declare function fontFile(): string | null;
13
+ /** 자막·CTA 오버레이 — 투명 PNG(영상과 같은 크기)를 구간별로 얹는다(overlay 필터 · 폰트 빌드 의존 없음). overlays 가 비면 복사만. */
14
+ export declare function burnCaptions(input: string, output: string, o: {
15
+ overlays: {
16
+ png: string;
17
+ start: number;
18
+ end: number;
19
+ }[];
20
+ }): Promise<void>;
21
+ /** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 소리는 유지(엔드카드 구간은 무음). */
22
+ export declare function appendEndCard(input: string, cardPng: string, output: string, seconds?: number): Promise<void>;
23
+ /** 비율 파생 — 중앙 크롭 후 표준 크기로 스케일(9:16 → 4:5 · 1:1, 16:9 → 1:1) */
24
+ export declare function deriveRatio(input: string, output: string, ratio: VideoRatio): Promise<void>;
25
+ /** 표준 크기로 정규화(올린 영상 · 코덱 통일 · 최대 60초) */
26
+ export declare function normalize(input: string, output: string, ratio: VideoRatio, maxSec?: number): Promise<Probe>;
27
+ /** 대표 프레임(썸네일) — 1초 지점 JPEG */
28
+ export declare function thumbnail(input: string, output: string, atSec?: number): Promise<void>;
@@ -0,0 +1,86 @@
1
+ // 영상 후처리(ffmpeg) — 자막 번인(헤드라인·CTA · Pretendard) · 엔드카드(로고·CTA 정지화면 2초) 이어붙이기 · 비율 파생(9:16 → 4:5·1:1 중앙 크롭) · 프로브.
2
+ // ffmpeg 가 없으면 원본 영상을 그대로 쓴다(호출자가 hasFfmpeg 로 분기).
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { HOME } from '../state.js';
9
+ import { VIDEO_DIMS } from './media.js';
10
+ const execFileP = promisify(execFile);
11
+ let ffmpegOk = null;
12
+ export async function hasFfmpeg() {
13
+ if (ffmpegOk !== null)
14
+ return ffmpegOk;
15
+ try {
16
+ await execFileP('ffmpeg', ['-version']);
17
+ await execFileP('ffprobe', ['-version']);
18
+ ffmpegOk = true;
19
+ }
20
+ catch {
21
+ ffmpegOk = false;
22
+ }
23
+ return ffmpegOk;
24
+ }
25
+ export async function probe(file) {
26
+ const { stdout } = await execFileP('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', file], { maxBuffer: 8 << 20 });
27
+ const j = JSON.parse(stdout);
28
+ const v = j.streams.find((s) => s.codec_type === 'video');
29
+ const a = j.streams.find((s) => s.codec_type === 'audio');
30
+ return { w: v?.width || 0, h: v?.height || 0, durationSec: Number(j.format.duration || v?.duration || 0), hasAudio: !!a };
31
+ }
32
+ export const ratioOf = (w, h) => { const r = w / h; if (r < 0.65)
33
+ return '9x16'; if (r < 0.9)
34
+ return '4x5'; if (r < 1.3)
35
+ return '1x1'; return '16x9'; };
36
+ /** Pretendard 굵은 글꼴 경로(render.ts 의 loadFonts 가 ~/.adpilot/fonts 에 내려받는다) */
37
+ export function fontFile() {
38
+ const cands = [path.join(HOME, 'fonts', 'Pretendard-Bold.otf'), path.join(HOME, 'fonts', 'Pretendard-ExtraBold.otf'), path.join(os.homedir(), 'Library/Fonts/Pretendard-Bold.otf'), '/usr/share/fonts/Pretendard-Bold.otf'];
39
+ return cands.find((f) => fs.existsSync(f)) || null;
40
+ }
41
+ /** 자막·CTA 오버레이 — 투명 PNG(영상과 같은 크기)를 구간별로 얹는다(overlay 필터 · 폰트 빌드 의존 없음). overlays 가 비면 복사만. */
42
+ export async function burnCaptions(input, output, o) {
43
+ const ov = o.overlays.filter((x) => fs.existsSync(x.png));
44
+ if (!ov.length) {
45
+ fs.copyFileSync(input, output);
46
+ return;
47
+ }
48
+ const p = await probe(input);
49
+ const inputs = ['-i', input];
50
+ for (const x of ov)
51
+ inputs.push('-i', x.png);
52
+ // 각 PNG 를 영상 크기로 맞춘 뒤 구간(enable) 동안만 얹는다 — 체인: [0:v] → [v0] → … → [v]
53
+ const fc = ov.map((x, i) => { const src = i === 0 ? '[0:v]' : `[v${i - 1}]`; const out = i === ov.length - 1 ? '[v]' : `[v${i}]`; return `[${i + 1}:v]scale=${p.w}:${p.h}[o${i}];${src}[o${i}]overlay=0:0:enable='between(t,${x.start.toFixed(2)},${x.end.toFixed(2)})'${out}`; }).join(';');
54
+ const args = ['-y', '-v', 'error', ...inputs, '-filter_complex', fc, '-map', '[v]', ...(p.hasAudio ? ['-map', '0:a', '-c:a', 'copy'] : []), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', output];
55
+ await execFileP('ffmpeg', args, { maxBuffer: 8 << 20 });
56
+ }
57
+ /** 엔드카드(PNG · 같은 크기) 를 seconds 초 정지화면으로 뒤에 이어붙인다. 소리는 유지(엔드카드 구간은 무음). */
58
+ export async function appendEndCard(input, cardPng, output, seconds = 2) {
59
+ const p = await probe(input);
60
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-end-'));
61
+ const card = path.join(dir, 'card.mp4');
62
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-loop', '1', '-t', String(seconds), '-i', cardPng, '-f', 'lavfi', '-t', String(seconds), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', `scale=${p.w}:${p.h}:force_original_aspect_ratio=increase,crop=${p.w}:${p.h},format=yuv420p,fps=24`, '-c:v', 'libx264', '-preset', 'veryfast', '-c:a', 'aac', '-shortest', card]);
63
+ const main = path.join(dir, 'main.mp4');
64
+ // 본편도 같은 코덱·프레임레이트·오디오로 정규화(무음이면 무음 트랙 추가) → concat 필터
65
+ const args = p.hasAudio
66
+ ? ['-y', '-v', 'error', '-i', input, '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-ar', '48000', '-ac', '2', main]
67
+ : ['-y', '-v', 'error', '-i', input, '-f', 'lavfi', '-t', String(p.durationSec), '-i', 'anullsrc=r=48000:cl=stereo', '-vf', 'fps=24,format=yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-shortest', main];
68
+ await execFileP('ffmpeg', args, { maxBuffer: 8 << 20 });
69
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', main, '-i', card, '-filter_complex', '[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]', '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ /** 비율 파생 — 중앙 크롭 후 표준 크기로 스케일(9:16 → 4:5 · 1:1, 16:9 → 1:1) */
73
+ export async function deriveRatio(input, output, ratio) {
74
+ const { w, h } = VIDEO_DIMS[ratio];
75
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', input, '-vf', `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},format=yuv420p`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'copy', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
76
+ }
77
+ /** 표준 크기로 정규화(올린 영상 · 코덱 통일 · 최대 60초) */
78
+ export async function normalize(input, output, ratio, maxSec = 60) {
79
+ const { w, h } = VIDEO_DIMS[ratio];
80
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', input, '-t', String(maxSec), '-vf', `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},format=yuv420p,fps=24`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-movflags', '+faststart', output], { maxBuffer: 8 << 20 });
81
+ return probe(output);
82
+ }
83
+ /** 대표 프레임(썸네일) — 1초 지점 JPEG */
84
+ export async function thumbnail(input, output, atSec = 1) {
85
+ await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', String(atSec), '-i', input, '-frames:v', '1', '-q:v', '3', output]);
86
+ }
package/dist/core/llm.js CHANGED
@@ -61,17 +61,24 @@ export function extractJson(text) {
61
61
  export async function completeJson(opts) {
62
62
  const system = `${opts.system}\n\n출력 규칙: 설명 없이 JSON 하나만 출력한다. 코드펜스를 쓰지 않는다. 문자열 안 줄바꿈은 \\n으로 이스케이프한다.`;
63
63
  let user = opts.user;
64
+ let last = '';
64
65
  for (let i = 0; i < 2; i++) {
65
66
  const raw = await complete({ ...opts, system, user });
66
67
  try {
67
68
  const r = opts.schema.safeParse(JSON.parse(extractJson(raw)));
68
69
  if (r.success)
69
70
  return r.data;
70
- user = `${opts.user}\n\n(직전 답이 형식과 달랐어요: ${r.error.issues.slice(0, 3).map((x) => `${x.path.join('.')}: ${x.message}`).join(' | ')}. 요구한 키와 타입을 정확히 지켜 JSON만 다시 출력해 주세요.)`;
71
+ last = r.error.issues.slice(0, 3).map((x) => `${x.path.join('.')}: ${x.message}`).join(' | ');
72
+ if (process.env.ADPILOT_LLM_DEBUG)
73
+ console.error('[llm] schema issues:', last, '\n', raw.slice(0, 1500));
74
+ user = `${opts.user}\n\n(직전 답이 형식과 달랐어요: ${last}. 요구한 키와 타입을 정확히 지켜 JSON만 다시 출력해 주세요.)`;
71
75
  }
72
76
  catch (e) {
77
+ last = `JSON 파싱 실패: ${e instanceof Error ? e.message.slice(0, 80) : e}`;
78
+ if (process.env.ADPILOT_LLM_DEBUG)
79
+ console.error('[llm] parse fail:', raw.slice(0, 1500));
73
80
  user = `${opts.user}\n\n(직전 답이 JSON으로 파싱되지 않았어요. JSON만 다시 출력해 주세요.)`;
74
81
  }
75
82
  }
76
- throw new Error('LLM이 요구한 JSON 형식을 두 번 모두 지키지 못했어요.');
83
+ throw new Error(`LLM이 요구한 JSON 형식을 두 번 모두 지키지 못했어요.${last ? ` (${last})` : ''}`);
77
84
  }
@@ -1,5 +1,6 @@
1
1
  import { connector } from '../adapters/index.js';
2
2
  import type { CheckItem, Medium, MetricRow } from '../adapters/types.js';
3
+ import { type CreativeMode, type CreativeSpec } from './creatives/media.js';
3
4
  import { type Proposal } from './optimize.js';
4
5
  import { type Goal, type Market, type Project } from './state.js';
5
6
  export type Log = (s: string) => void;
@@ -21,6 +22,11 @@ export declare function opCreatives(p: Project, o: {
21
22
  images?: string;
22
23
  noImages?: boolean;
23
24
  force?: boolean;
25
+ mode?: CreativeMode;
26
+ video?: boolean;
27
+ videoSeconds?: number;
28
+ requirements?: string;
29
+ spec?: CreativeSpec;
24
30
  log?: Log;
25
31
  }): Promise<{
26
32
  project: Project;
package/dist/core/ops.js CHANGED
@@ -5,6 +5,8 @@ import { connectedMedia, connector } from '../adapters/index.js';
5
5
  import { buildBrief, explainBrief } from './brief.js';
6
6
  import { generateConcepts, validateConcepts } from './creatives/copy.js';
7
7
  import { generateBackgrounds, userBackgrounds } from './creatives/images.js';
8
+ import { chargeFor, resolveCreativePlan } from './creatives/media.js';
9
+ import { produceConceptVideos } from './creatives/factory.js';
8
10
  import { renderConcept, renderLogo } from './creatives/render.js';
9
11
  import { SIZES } from './creatives/specs.js';
10
12
  import { aiAvailable, completeJson } from './llm.js';
@@ -50,11 +52,22 @@ export async function opCreatives(p, o) {
50
52
  throw new Error('브리프가 먼저 필요해요.');
51
53
  if (p.brief.specialCategory)
52
54
  throw new Error(`「${p.brief.specialCategory}」 특별 광고 카테고리는 접수할 수 없어요.`);
53
- const count = o.count || 3;
55
+ // 소재 설정(형태·출처·영상) 명시 spec > 플래그 > 프로젝트 저장값. CLI 는 --video 없으면 이미지만(비용 예측 가능).
56
+ const spec = { ...(p.creative || {}), ...(o.spec || {}), ...(o.mode ? { mode: o.mode } : {}), ...(o.requirements ? { source: 'guided', prompt: o.requirements } : {}) };
57
+ if (o.video === false || (o.video === undefined && !o.spec?.video && !p.creative?.video && !o.mode))
58
+ spec.video = { ...(spec.video || {}), count: 0 };
59
+ if (o.video && !(spec.video?.count))
60
+ spec.video = { ...(spec.video || {}), count: 1 };
61
+ if (o.videoSeconds)
62
+ spec.video = { ...(spec.video || {}), durationSec: (o.videoSeconds <= 8 ? 8 : o.videoSeconds <= 15 ? 15 : 30) };
63
+ const cplan = resolveCreativePlan(spec, { monthlyKrw: p.budget.monthlyKrw, media: connectedMedia() });
64
+ p.creative = spec;
65
+ const count = o.count || cplan.concepts;
54
66
  const dir = creativesDir(p.slug);
67
+ log(cplan.explain);
55
68
  if (o.force || o.feedback || !p.concepts?.length) {
56
69
  log(`컨셉 ${count}개·카피 만드는 중…${o.feedback ? ` (수정 요청: ${o.feedback})` : ''}`);
57
- p.concepts = await generateConcepts(p.brief, { goal: p.budget.goal, count, feedback: o.feedback, previous: p.concepts });
70
+ p.concepts = await generateConcepts(p.brief, { goal: p.budget.goal, count, feedback: o.feedback, previous: p.concepts, requirements: spec.prompt, userCopy: spec.copy, video: cplan.videos.perConcept.length > 0 });
58
71
  if (o.feedback || o.force)
59
72
  for (const f of fs.readdirSync(dir))
60
73
  if (/^(?!bg_).*\.png$/.test(f))
@@ -70,19 +83,28 @@ export async function opCreatives(p, o) {
70
83
  const assets = [];
71
84
  // 로고 자산(구글 RDA 용 1:1 · 4:1)
72
85
  assets.push(...(await renderLogo(dir, p.brief)));
73
- for (const c of p.concepts) {
86
+ let costKrw = 0;
87
+ const refs = o.images ? fs.readdirSync(o.images).filter((f) => /\.(png|jpe?g|webp)$/i.test(f)).map((f) => path.join(o.images, f)) : [];
88
+ p.concepts.forEach((c, i) => { void i; });
89
+ for (const [ci, c] of p.concepts.entries()) {
74
90
  let bgs;
75
- if (o.images)
91
+ if (o.images && spec.source !== 'guided')
76
92
  bgs = userBackgrounds(o.images, c.key);
77
- else if (o.noImages)
93
+ else if (o.noImages || !cplan.images.enabled)
78
94
  bgs = {};
79
95
  else {
80
96
  log(`배경 이미지 생성 ${c.key}…`);
81
- bgs = await generateBackgrounds(dir, c.key, c.imagePrompt, undefined, log);
97
+ bgs = await generateBackgrounds(dir, c.key, c.imagePrompt, undefined, log, { model: cplan.images.model, refs: spec.source === 'guided' ? refs : undefined, onAsset: (a) => { costKrw += a.costKrw; } });
82
98
  }
83
99
  log(`합성 ${c.key} × ${SIZES.length}규격…`);
84
100
  assets.push(...(await renderConcept(dir, p.brief, c, bgs, { force: o.force || !!o.feedback, log })));
101
+ if (cplan.videos.perConcept.length && ci < cplan.videos.conceptsWithVideo) {
102
+ const vids = await produceConceptVideos({ dir, brief: p.brief, concept: c, jobs: cplan.videos.perConcept, backgrounds: bgs, refs, log, onAsset: (a) => { costKrw += a.costKrw; } });
103
+ assets.push(...vids.filter((v) => v.file));
104
+ }
85
105
  }
106
+ if (costKrw)
107
+ log(`소재 생성 원가 ₩${Math.round(costKrw).toLocaleString()} (청구 기준 ₩${chargeFor(costKrw).toLocaleString()})`);
86
108
  p.assets = assets;
87
109
  p.status = 'creatives';
88
110
  fs.writeFileSync(path.join(dir, 'copy.json'), JSON.stringify(p.concepts, null, 2));
@@ -24,9 +24,15 @@ export type Config = {
24
24
  model?: string;
25
25
  };
26
26
  image?: {
27
- provider?: 'openai';
27
+ provider?: 'openai' | 'bizrouter';
28
28
  apiKey?: string;
29
29
  model?: string;
30
+ baseUrl?: string;
31
+ };
32
+ video?: {
33
+ apiKey?: string;
34
+ arkApiKey?: string;
35
+ model?: string;
30
36
  };
31
37
  /** 원화 기준 환율 — 매체 계정 통화가 KRW가 아닐 때 예산 환산에 쓴다 */
32
38
  fx?: Record<string, number>;
@@ -67,7 +73,12 @@ export type Concept = {
67
73
  cta: string;
68
74
  imagePrompt: string;
69
75
  theme: 'dark' | 'light';
76
+ /** 영상 프롬프트(영어 · 8~30초 광고 한 컷 연출) · 화면 자막 2~3줄 · 대사/내레이션 한 줄(선택) */
77
+ videoPrompt?: string;
78
+ videoLines?: string[];
79
+ videoVoice?: string;
70
80
  };
81
+ /** 소재 자산 — 이미지(PNG) 또는 영상(MP4). type 이 없으면 이미지. costKrw 는 생성 원가(원). variant 는 같은 크기의 A/B 변형 구분(기본 ''). */
71
82
  export type CreativeAsset = {
72
83
  concept: string;
73
84
  w: number;
@@ -75,6 +86,12 @@ export type CreativeAsset = {
75
86
  ratio: string;
76
87
  file: string;
77
88
  medium: 'meta' | 'google' | 'both';
89
+ type?: 'image' | 'video';
90
+ variant?: string;
91
+ durationSec?: number;
92
+ mime?: string;
93
+ costKrw?: number;
94
+ origin?: 'ai' | 'upload';
78
95
  };
79
96
  export type PlanMedium = {
80
97
  medium: 'meta' | 'google';
@@ -99,6 +116,7 @@ export type Placement = {
99
116
  medium: 'meta' | 'google';
100
117
  kind: string;
101
118
  concept?: string;
119
+ format?: 'image' | 'video';
102
120
  externalId: string;
103
121
  name: string;
104
122
  status: string;
@@ -120,6 +138,8 @@ export type Project = {
120
138
  brief?: Brief;
121
139
  concepts?: Concept[];
122
140
  assets?: CreativeAsset[];
141
+ /** 소재 만들기 설정(형태·출처·영상 옵션) — creatives/media.ts 의 CreativeSpec */
142
+ creative?: import('./creatives/media.js').CreativeSpec;
123
143
  plan?: Plan;
124
144
  placements: Placement[];
125
145
  tracking?: {
package/dist/index.d.ts CHANGED
@@ -7,6 +7,10 @@ export { SIZES, BG_RATIOS, wlen, fit, GOOGLE_LIMITS, GOOGLE_CTA, META_CTA, ctaFo
7
7
  export * from './core/creatives/copy.js';
8
8
  export * from './core/creatives/images.js';
9
9
  export * from './core/creatives/render.js';
10
+ export * from './core/creatives/media.js';
11
+ export * from './core/creatives/video.js';
12
+ export * from './core/creatives/videofx.js';
13
+ export * from './core/creatives/factory.js';
10
14
  export { computePlan, type PlanInput } from './core/plan.js';
11
15
  export * from './core/optimize.js';
12
16
  export * from './core/report.js';
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ export { SIZES, BG_RATIOS, wlen, fit, GOOGLE_LIMITS, GOOGLE_CTA, META_CTA, ctaFo
8
8
  export * from './core/creatives/copy.js';
9
9
  export * from './core/creatives/images.js';
10
10
  export * from './core/creatives/render.js';
11
+ export * from './core/creatives/media.js';
12
+ export * from './core/creatives/video.js';
13
+ export * from './core/creatives/videofx.js';
14
+ export * from './core/creatives/factory.js';
11
15
  export { computePlan } from './core/plan.js';
12
16
  export * from './core/optimize.js';
13
17
  export * from './core/report.js';
package/dist/mcp.js CHANGED
@@ -34,9 +34,9 @@ export async function startMcp() {
34
34
  catch (e) {
35
35
  return err(e);
36
36
  } });
37
- server.registerTool('adyou_creatives', { description: '컨셉·카피 생성 + 배경 이미지 + 12규격 소재 합성 + 규격 검사. feedback을 주면 그 방향으로 전부 다시 만든다.', inputSchema: { project: z.string().optional(), concepts: z.number().optional(), feedback: z.string().optional(), imagesFolder: z.string().optional(), noImages: z.boolean().optional() } }, async (a) => { try {
37
+ server.registerTool('adyou_creatives', { description: '컨셉·카피 생성 + 배경 이미지 + 12규격 소재 합성 + 규격 검사. feedback을 주면 그 방향으로 전부 다시 만든다.', inputSchema: { project: z.string().optional(), concepts: z.number().optional(), feedback: z.string().optional(), mode: z.enum(['auto', 'image', 'video', 'text']).optional(), video: z.boolean().optional(), videoSeconds: z.number().optional(), requirements: z.string().optional(), imagesFolder: z.string().optional(), noImages: z.boolean().optional() } }, async (a) => { try {
38
38
  const logs = [];
39
- const { project, report } = await opCreatives(proj(a.project), { count: a.concepts, feedback: a.feedback, images: a.imagesFolder, noImages: a.noImages, log: (s) => logs.push(s) });
39
+ const { project, report } = await opCreatives(proj(a.project), { count: a.concepts, feedback: a.feedback, images: a.imagesFolder, noImages: a.noImages, mode: a.mode, video: a.video, videoSeconds: a.videoSeconds, requirements: a.requirements, log: (s) => logs.push(s) });
40
40
  return text(`컨셉 ${project.concepts.length} · 소재 ${project.assets.length}장\n${project.concepts.map((c) => `· ${c.name}(${c.key}) — ${c.headlines[0]} / ${c.bodies[0]}`).join('\n')}\n${report.length ? `규격 경고:\n${report.join('\n')}\n` : ''}갤러리: ~/.adpilot/projects/${project.slug}/creatives/index.html`);
41
41
  }
42
42
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",