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
package/dist/adapters/meta.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export declare class MetaConnector implements Connector {
|
|
|
15
15
|
ensurePixel(name: string): Promise<string>;
|
|
16
16
|
inventory(): Promise<InventoryRow[]>;
|
|
17
17
|
private findByName;
|
|
18
|
+
/** 영상 업로드(multipart source) → 처리 완료(ready)까지 대기 → video_id. 🔴 크리에이티브는 video_status 가 ready 여야 만들 수 있다 */
|
|
19
|
+
private uploadVideo;
|
|
18
20
|
private uploadImage;
|
|
19
21
|
build(input: BuildInput): Promise<BuildResult>;
|
|
20
22
|
preview(placements: Placement[]): Promise<{
|
package/dist/adapters/meta.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 예산=계정 통화 최소 단위 · Advantage+ 오디언스에선 age_max 금지 · DCO 광고세트엔 광고 1개(컨셉=광고세트) · instagram_user_id · WITH_ISSUES 2446984는 조치 불필요
|
|
3
3
|
// 앱 개발 모드면 크리에이티브 생성 100 오류 · 픽셀 트래픽 권한 밖 도메인은 이벤트 조용히 폐기(signals/config blockReason)
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
-
import { MediumError, nameTag, utm } from './types.js';
|
|
5
|
+
import { abKey, MediumError, nameTag, utm } from './types.js';
|
|
6
6
|
const G = 'https://graph.facebook.com/v23.0';
|
|
7
7
|
const REQUIRED_SCOPES = ['ads_management', 'ads_read', 'business_management', 'pages_show_list', 'pages_read_engagement', 'pages_manage_ads'];
|
|
8
8
|
const COUNTRY_INTERESTS = {};
|
|
@@ -145,6 +145,29 @@ export class MetaConnector {
|
|
|
145
145
|
const d = await this.call('GET', `${this.c.adAccountId}/${edge}`, { fields: 'id,name,status', limit: 500, filtering: [{ field: 'name', operator: 'CONTAIN', value: name.slice(0, 60) }], ...extra });
|
|
146
146
|
return d.data.find((x) => x.name === name) || null;
|
|
147
147
|
}
|
|
148
|
+
/** 영상 업로드(multipart source) → 처리 완료(ready)까지 대기 → video_id. 🔴 크리에이티브는 video_status 가 ready 여야 만들 수 있다 */
|
|
149
|
+
async uploadVideo(file, title, log) {
|
|
150
|
+
const fd = new FormData();
|
|
151
|
+
fd.set('access_token', this.c.accessToken);
|
|
152
|
+
fd.set('title', title.slice(0, 200));
|
|
153
|
+
fd.set('source', new Blob([fs.readFileSync(file)], { type: 'video/mp4' }), 'ad.mp4');
|
|
154
|
+
const res = await fetch(`${G}/${this.c.adAccountId}/advideos`, { method: 'POST', body: fd, signal: AbortSignal.timeout(600_000) });
|
|
155
|
+
const d = (await res.json().catch(() => ({})));
|
|
156
|
+
if (!res.ok || d.error || !d.id)
|
|
157
|
+
throw this.wrap(d.error || { message: `영상 업로드 실패 ${res.status}` }, 'advideos');
|
|
158
|
+
const t0 = Date.now();
|
|
159
|
+
while (Date.now() - t0 < 10 * 60_000) {
|
|
160
|
+
const st = await this.call('GET', d.id, { fields: 'status' });
|
|
161
|
+
const vs = st.status?.video_status;
|
|
162
|
+
if (vs === 'ready')
|
|
163
|
+
return d.id;
|
|
164
|
+
if (vs === 'error')
|
|
165
|
+
throw new MediumError('meta', '메타가 영상을 처리하지 못했어요(코덱·길이 확인)');
|
|
166
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
167
|
+
}
|
|
168
|
+
log(' ⚠ 영상 처리 대기 10분 초과 — 그대로 진행');
|
|
169
|
+
return d.id;
|
|
170
|
+
}
|
|
148
171
|
async uploadImage(file) {
|
|
149
172
|
const d = await this.call('POST', `${this.c.adAccountId}/adimages`, { bytes: fs.readFileSync(file).toString('base64') });
|
|
150
173
|
const first = Object.values(d.images)[0];
|
|
@@ -156,7 +179,7 @@ export class MetaConnector {
|
|
|
156
179
|
const log = input.log || (() => { });
|
|
157
180
|
const warnings = [];
|
|
158
181
|
const placements = [...input.existing];
|
|
159
|
-
const has = (kind, concept) => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept : true));
|
|
182
|
+
const has = (kind, concept, format = 'image') => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept && (p.format || 'image') === format : true));
|
|
160
183
|
const tag = nameTag(input.slug);
|
|
161
184
|
if (!this.c.pageId)
|
|
162
185
|
throw new MediumError('meta', '페이지 id가 없어요', '`adpilot connect meta --page <페이지 id>`');
|
|
@@ -181,43 +204,86 @@ export class MetaConnector {
|
|
|
181
204
|
const targeting = { geo_locations: { countries: input.markets.map((m) => m.country.toUpperCase()) }, age_min: 18, targeting_automation: { advantage_audience: 1 }, ...(input.markets.length === 1 && input.markets[0].language ? { locales: undefined } : {}) };
|
|
182
205
|
const domain = new URL(input.landingUrl).hostname;
|
|
183
206
|
for (const c of input.concepts) {
|
|
184
|
-
const imgs = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both'));
|
|
185
|
-
|
|
207
|
+
const imgs = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && (a.type || 'image') === 'image');
|
|
208
|
+
const vids = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && a.type === 'video');
|
|
209
|
+
if (!imgs.length && !vids.length) {
|
|
186
210
|
warnings.push(`${c.key}: 메타용 소재가 없어 건너뜀`);
|
|
187
211
|
continue;
|
|
188
212
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
if (imgs.length) {
|
|
214
|
+
let adset = has('adset', c.key);
|
|
215
|
+
if (!adset) {
|
|
216
|
+
const name = `${tag} ${c.key} · ${input.markets.map((m) => m.country).join('+')} · DCO`;
|
|
217
|
+
const found = await this.findByName('adsets', name);
|
|
218
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
|
|
219
|
+
adset = { medium: 'meta', kind: 'adset', concept: c.key, externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
220
|
+
placements.push(adset);
|
|
221
|
+
log(` meta 광고세트 ${c.key} ${found ? '재사용' : '생성'} ${id}`);
|
|
222
|
+
}
|
|
223
|
+
let creative = has('creative', c.key);
|
|
224
|
+
if (!creative) {
|
|
225
|
+
const hashes = [];
|
|
226
|
+
for (const a of imgs.filter((x) => [1080].includes(x.w)))
|
|
227
|
+
hashes.push(await this.uploadImage(a.file));
|
|
228
|
+
const name = `${tag} ${c.key} · DCO`;
|
|
229
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
230
|
+
name,
|
|
231
|
+
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
232
|
+
asset_feed_spec: {
|
|
233
|
+
images: hashes.map((h) => ({ hash: h })), bodies: c.bodies.slice(0, 5).map((t) => ({ text: t })), titles: c.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: c.descriptions.slice(0, 2).map((t) => ({ text: t })),
|
|
234
|
+
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, c.key), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_IMAGE'],
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
creative = { medium: 'meta', kind: 'creative', concept: c.key, externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
238
|
+
placements.push(creative);
|
|
239
|
+
log(` meta 크리에이티브 ${c.key} 생성 ${d.id} (이미지 ${hashes.length})`);
|
|
240
|
+
}
|
|
241
|
+
if (!has('ad', c.key)) {
|
|
242
|
+
const name = `${tag} ${c.key}`;
|
|
243
|
+
const d = await this.call('POST', `${this.c.adAccountId}/ads`, { name, adset_id: adset.externalId, creative: { creative_id: creative.externalId }, status: 'PAUSED', ...(input.pixelId ? { conversion_domain: domain } : {}) });
|
|
244
|
+
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
245
|
+
log(` meta 광고 ${c.key} 생성 ${d.id}`);
|
|
246
|
+
}
|
|
215
247
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
248
|
+
// 영상 광고 — 컨셉마다 별도 DCO 광고세트(형태 A/B: 이미지 vs 영상) · 9:16 원본 + 파생 비율을 한 크리에이티브의 변형으로
|
|
249
|
+
if (vids.length) {
|
|
250
|
+
let adset = has('adset', c.key, 'video');
|
|
251
|
+
if (!adset) {
|
|
252
|
+
const name = `${tag} ${c.key} · ${input.markets.map((m) => m.country).join('+')} · VIDEO`;
|
|
253
|
+
const found = await this.findByName('adsets', name);
|
|
254
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
|
|
255
|
+
adset = { medium: 'meta', kind: 'adset', concept: c.key, format: 'video', externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
256
|
+
placements.push(adset);
|
|
257
|
+
log(` meta 영상 광고세트 ${c.key} ${found ? '재사용' : '생성'} ${id}`);
|
|
258
|
+
}
|
|
259
|
+
let creative = has('creative', c.key, 'video');
|
|
260
|
+
if (!creative) {
|
|
261
|
+
const videos = [];
|
|
262
|
+
for (const v of vids.slice(0, 3)) {
|
|
263
|
+
const vid = await this.uploadVideo(v.file, `${tag} ${c.key} ${v.ratio}`, log);
|
|
264
|
+
const thumb = imgs.find((a) => a.ratio === v.ratio && a.w === 1080) || imgs.find((a) => a.w === 1080);
|
|
265
|
+
videos.push({ video_id: vid, ...(thumb ? { thumbnail_hash: await this.uploadImage(thumb.file) } : {}) });
|
|
266
|
+
log(` meta 영상 업로드 ${c.key} ${v.ratio} → ${vid}`);
|
|
267
|
+
}
|
|
268
|
+
const name = `${tag} ${c.key} · VIDEO · DCO`;
|
|
269
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
270
|
+
name,
|
|
271
|
+
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
272
|
+
asset_feed_spec: {
|
|
273
|
+
videos, bodies: c.bodies.slice(0, 5).map((t) => ({ text: t })), titles: c.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: c.descriptions.slice(0, 2).map((t) => ({ text: t })),
|
|
274
|
+
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, abKey(c.key, 'video')), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_VIDEO'],
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
creative = { medium: 'meta', kind: 'creative', concept: c.key, format: 'video', externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
278
|
+
placements.push(creative);
|
|
279
|
+
log(` meta 영상 크리에이티브 ${c.key} 생성 ${d.id} (영상 ${videos.length})`);
|
|
280
|
+
}
|
|
281
|
+
if (!has('ad', c.key, 'video')) {
|
|
282
|
+
const name = `${tag} ${c.key} · video`;
|
|
283
|
+
const d = await this.call('POST', `${this.c.adAccountId}/ads`, { name, adset_id: adset.externalId, creative: { creative_id: creative.externalId }, status: 'PAUSED', ...(input.pixelId ? { conversion_domain: domain } : {}) });
|
|
284
|
+
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, format: 'video', externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
285
|
+
log(` meta 영상 광고 ${c.key} 생성 ${d.id}`);
|
|
286
|
+
}
|
|
221
287
|
}
|
|
222
288
|
}
|
|
223
289
|
return { placements, warnings };
|
|
@@ -229,7 +295,7 @@ export class MetaConnector {
|
|
|
229
295
|
try {
|
|
230
296
|
const d = await this.call('GET', `${ad.externalId}/previews`, { ad_format: fmt });
|
|
231
297
|
if (d.data[0]?.body)
|
|
232
|
-
out.push({ concept: ad.concept, name: `Meta · ${fmt === 'INSTAGRAM_STANDARD' ? 'Instagram' : 'Facebook 피드'} · ${ad.concept}`, html: d.data[0].body });
|
|
298
|
+
out.push({ concept: ad.concept, name: `Meta · ${fmt === 'INSTAGRAM_STANDARD' ? 'Instagram' : 'Facebook 피드'} · ${ad.concept}${ad.format === 'video' ? ' · 영상' : ''}`, html: d.data[0].body });
|
|
233
299
|
}
|
|
234
300
|
catch (e) {
|
|
235
301
|
out.push({ concept: ad.concept, name: `Meta · ${fmt}`, html: `<p>미리보기 실패: ${e instanceof Error ? e.message : e}</p>` });
|
|
@@ -257,7 +323,7 @@ export class MetaConnector {
|
|
|
257
323
|
const acc = await this.account();
|
|
258
324
|
const d = await this.call('GET', `${this.c.adAccountId}/insights`, { level: 'ad', fields: 'ad_id,ad_name,adset_id,spend,impressions,clicks,actions', time_range: { since: range.since, until: range.until }, time_increment: 1, filtering: [{ field: 'campaign.id', operator: 'IN', value: [camp.externalId] }], limit: 1000 });
|
|
259
325
|
const conv = (acts) => (acts || []).filter((a) => ['purchase', 'lead', 'complete_registration', 'offsite_conversion.fb_pixel_purchase', 'offsite_conversion.fb_pixel_lead', 'offsite_conversion.fb_pixel_complete_registration'].includes(a.action_type)).reduce((n, a) => n + Number(a.value), 0);
|
|
260
|
-
return d.data.map((r) => ({ date: r.date_start, level: 'ad', id: r.ad_id, name: r.ad_name, concept: placements.find((p) => p.externalId === r.ad_id)?.concept, spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), currency: acc.currency }));
|
|
326
|
+
return d.data.map((r) => ({ date: r.date_start, level: 'ad', id: r.ad_id, name: r.ad_name, concept: placements.find((p) => p.externalId === r.ad_id)?.concept, format: placements.find((p) => p.externalId === r.ad_id)?.format || 'image', spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), currency: acc.currency }));
|
|
261
327
|
}
|
|
262
328
|
async policy(placements) {
|
|
263
329
|
const out = [];
|
package/dist/adapters/mock.js
CHANGED
|
@@ -28,8 +28,8 @@ export class MockConnector {
|
|
|
28
28
|
const st = this.load();
|
|
29
29
|
const placements = [...input.existing];
|
|
30
30
|
const tag = nameTag(input.slug);
|
|
31
|
-
const mk = (kind, concept) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept); if (existing)
|
|
32
|
-
return existing; const p = { medium: this.medium, kind, concept, externalId: `${this.medium}_${kind}_${concept || 'root'}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}`.trim(), status: 'PAUSED', createdAt: new Date().toISOString() }; placements.push(p); st.objects.push(p); return p; };
|
|
31
|
+
const mk = (kind, concept, format) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept && (p.format || 'image') === (format || 'image')); if (existing)
|
|
32
|
+
return existing; const p = { medium: this.medium, kind, concept, ...(format ? { format } : {}), externalId: `${this.medium}_${kind}_${concept || 'root'}${format === 'video' ? '_video' : ''}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}${format === 'video' ? ' · video' : ''}`.trim(), status: 'PAUSED', createdAt: new Date().toISOString() }; placements.push(p); st.objects.push(p); return p; };
|
|
33
33
|
if (input.validateOnly) {
|
|
34
34
|
st.calls.push('validate');
|
|
35
35
|
this.save(st);
|
|
@@ -39,11 +39,21 @@ export class MockConnector {
|
|
|
39
39
|
if (this.medium === 'google')
|
|
40
40
|
mk('adgroup');
|
|
41
41
|
for (const c of input.concepts) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
const imgs = input.assets.filter((a) => a.concept === c.key && (a.type || 'image') === 'image');
|
|
43
|
+
const vids = input.assets.filter((a) => a.concept === c.key && a.type === 'video');
|
|
44
|
+
if (imgs.length || !vids.length) {
|
|
45
|
+
if (this.medium === 'meta') {
|
|
46
|
+
mk('adset', c.key);
|
|
47
|
+
mk('creative', c.key);
|
|
48
|
+
}
|
|
49
|
+
mk('ad', c.key);
|
|
50
|
+
}
|
|
51
|
+
// 영상은 메타만(구글 디스플레이는 유튜브 영상만 받는다) · 컨셉별 형태 A/B
|
|
52
|
+
if (vids.length && this.medium === 'meta') {
|
|
53
|
+
mk('adset', c.key, 'video');
|
|
54
|
+
mk('creative', c.key, 'video');
|
|
55
|
+
mk('ad', c.key, 'video');
|
|
45
56
|
}
|
|
46
|
-
mk('ad', c.key);
|
|
47
57
|
}
|
|
48
58
|
st.calls.push(`build budget=${input.dailyBudgetMinor} ${input.currency}`);
|
|
49
59
|
this.save(st);
|
|
@@ -59,7 +69,7 @@ export class MockConnector {
|
|
|
59
69
|
const d0 = new Date(range.since);
|
|
60
70
|
const d1 = new Date(range.until);
|
|
61
71
|
for (let d = new Date(d0); d <= d1; d.setDate(d.getDate() + 1))
|
|
62
|
-
ads.forEach((a, i) => { const imp = 1000 + i * 400; const clicks = Math.round(imp * (0.008 + i * 0.006)); out.push({ date: d.toISOString().slice(0, 10), level: 'ad', id: a.externalId, name: a.name, concept: a.concept, spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
|
|
72
|
+
ads.forEach((a, i) => { const imp = 1000 + i * 400; const clicks = Math.round(imp * (0.008 + i * 0.006)); out.push({ date: d.toISOString().slice(0, 10), level: 'ad', id: a.externalId, name: a.name, concept: a.concept, format: a.format || 'image', spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
|
|
63
73
|
return out;
|
|
64
74
|
}
|
|
65
75
|
async policy(placements) { return placements.filter((p) => p.medium === this.medium && p.kind === 'ad').map((p) => ({ id: p.externalId, name: p.name, status: 'APPROVED', issues: [] })); }
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type MetricRow = {
|
|
|
44
44
|
id: string;
|
|
45
45
|
name: string;
|
|
46
46
|
concept?: string;
|
|
47
|
+
format?: 'image' | 'video';
|
|
47
48
|
spend: number;
|
|
48
49
|
impressions: number;
|
|
49
50
|
clicks: number;
|
|
@@ -98,3 +99,5 @@ export declare class MediumError extends Error {
|
|
|
98
99
|
}
|
|
99
100
|
export declare const utm: (url: string, medium: Medium, campaign: string, content: string) => string;
|
|
100
101
|
export declare const nameTag: (slug: string) => string;
|
|
102
|
+
/** A/B 키 — 컨셉(+영상이면 _video) · utm_content 와 광고 이름에 같이 쓴다 */
|
|
103
|
+
export declare const abKey: (concept: string, format?: "image" | "video") => string;
|
package/dist/adapters/types.js
CHANGED
|
@@ -18,3 +18,5 @@ export const utm = (url, medium, campaign, content) => {
|
|
|
18
18
|
return u.toString();
|
|
19
19
|
};
|
|
20
20
|
export const nameTag = (slug) => `[adp:${slug}]`;
|
|
21
|
+
/** A/B 키 — 컨셉(+영상이면 _video) · utm_content 와 광고 이름에 같이 쓴다 */
|
|
22
|
+
export const abKey = (concept, format) => (format === 'video' ? `${concept}_video` : concept);
|
package/dist/cli.js
CHANGED
|
@@ -104,10 +104,10 @@ program.command('brief <url>').description('사이트 읽고 브리프 만들기
|
|
|
104
104
|
die(e);
|
|
105
105
|
}
|
|
106
106
|
});
|
|
107
|
-
program.command('creatives').description('컨셉·카피 + 이미지 생성 + 12규격 합성 + 규격 검사').option('--concepts <n>', '컨셉 수', '3').option('--feedback <text>', '자연어 수정(「더 고급스럽게」) — 전부 다시 만든다').option('--images <folder>', '직접 준비한 배경 이미지 폴더').option('--no-images', '이미지 생성 없이 브랜드 그래디언트').option('--force', '다시 만들기').action(async (o) => {
|
|
107
|
+
program.command('creatives').description('컨셉·카피 + 이미지 생성 + 12규격 합성 + 규격 검사').option('--concepts <n>', '컨셉 수', '3').option('--feedback <text>', '자연어 수정(「더 고급스럽게」) — 전부 다시 만든다').option('--images <folder>', '직접 준비한 배경 이미지 폴더').option('--no-images', '이미지 생성 없이 브랜드 그래디언트').option('--force', '다시 만들기').option('--mode <mode>', '소재 형태 auto|image|video|text').option('--video', '영상 소재도 만든다(9:16 8초 · Veo 3.1 · 소리 포함)').option('--video-seconds <n>', '영상 길이 8|15|30 (15/30 은 Seedance 2.5)').option('--requirements <text>', '요구사항·방향(참고 이미지는 --images 폴더)').action(async (o) => {
|
|
108
108
|
try {
|
|
109
109
|
const p = resolveProject(program.opts().project);
|
|
110
|
-
const { project, report } = await opCreatives(p, { count: Number(o.concepts), feedback: o.feedback, images: o.images, noImages: o.images === false, force: o.force, log });
|
|
110
|
+
const { project, report } = await opCreatives(p, { count: Number(o.concepts), feedback: o.feedback, images: o.images, noImages: o.images === false, force: o.force, mode: o.mode, video: o.video, videoSeconds: o.videoSeconds ? Number(o.videoSeconds) : undefined, requirements: o.requirements, log });
|
|
111
111
|
log(`\n컨셉 ${project.concepts.length}개 · 소재 ${project.assets.filter((a) => a.concept !== '_logo').length}장`);
|
|
112
112
|
for (const c of project.concepts)
|
|
113
113
|
log(` · ${c.name}(${c.key}) — ${c.headlines[0]}`);
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { Brief, Concept, Goal } from '../state.js';
|
|
2
2
|
import { wlen } from './specs.js';
|
|
3
|
-
export
|
|
3
|
+
export type ConceptOpts = {
|
|
4
4
|
goal: Goal;
|
|
5
5
|
count: number;
|
|
6
6
|
feedback?: string;
|
|
7
|
-
previous?: Concept[];
|
|
8
|
-
|
|
7
|
+
previous?: Concept[]; /** guided: 광고주 요구사항·방향 */
|
|
8
|
+
requirements?: string; /** 참고 이미지 설명(있으면 비주얼을 그 제품·인물에 맞춘다) */
|
|
9
|
+
referenceNote?: string; /** manual: 광고주가 직접 쓴 문구 — 이걸 다듬어 규격에 맞추기만 한다 */
|
|
10
|
+
userCopy?: {
|
|
11
|
+
headlines?: string[];
|
|
12
|
+
bodies?: string[];
|
|
13
|
+
descriptions?: string[];
|
|
14
|
+
}; /** 영상 프롬프트·자막도 만들지 */
|
|
15
|
+
video?: boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function generateConcepts(brief: Brief, opts: ConceptOpts): Promise<Concept[]>;
|
|
9
18
|
/** 매체별 카피 규격 검사 요약 */
|
|
10
19
|
export declare function validateConcepts(concepts: Concept[]): {
|
|
11
20
|
ok: boolean;
|
|
@@ -3,9 +3,10 @@ import { z } from 'zod';
|
|
|
3
3
|
import { aiAvailable, completeJson } from '../llm.js';
|
|
4
4
|
import { checkCopy, ctaFor, fit, GOOGLE_LIMITS, wlen } from './specs.js';
|
|
5
5
|
const ConceptSchema = z.object({
|
|
6
|
-
key: z.string().regex(/^[a-z][a-z0-9_]{1,19}$/), name: z.string(), angle: z.string(),
|
|
7
|
-
headlines: z.array(z.string()).min(
|
|
6
|
+
key: z.string().regex(/^[a-z][a-z0-9_]{1,19}$/), name: z.string().optional(), angle: z.string().optional(),
|
|
7
|
+
headlines: z.array(z.string()).min(1).max(8), bodies: z.array(z.string()).min(1).max(8), descriptions: z.array(z.string()).min(1).max(4),
|
|
8
8
|
imagePrompt: z.string(), theme: z.enum(['dark', 'light']).default('dark'),
|
|
9
|
+
videoPrompt: z.string().optional(), videoLines: z.array(z.string()).optional(), videoVoice: z.string().optional().nullable(),
|
|
9
10
|
});
|
|
10
11
|
export async function generateConcepts(brief, opts) {
|
|
11
12
|
const cta = ctaFor(opts.goal);
|
|
@@ -15,20 +16,38 @@ export async function generateConcepts(brief, opts) {
|
|
|
15
16
|
schema: z.object({ concepts: z.array(ConceptSchema).min(1) }), maxTokens: 6000, timeoutMs: 120_000,
|
|
16
17
|
system: `너는 퍼포먼스 광고 카피라이터이자 아트디렉터다. 브리프로 서로 다른 각도의 광고 컨셉 ${opts.count}개를 만든다(혜택·증거/사회적 증명·긴급/희소·비교/대안·감성 중에서 고르되 겹치지 않게).
|
|
17
18
|
규칙:
|
|
18
|
-
- 언어는 brief.language. 사실은 브리프(usp·offer)에 있는 것만. 과장·최상급(최고·1위·100%·보장·무조건)·클릭 유도 금지.
|
|
19
|
+
- 언어: headlines·bodies·descriptions·videoLines·videoVoice·name·angle 전부 brief.language(${brief.language}) 하나로 — 다른 언어를 섞지 않는다(요구사항이 한국어로 적혀 있어도 출력 언어는 brief.language). 사실은 브리프(usp·offer)에 있는 것만. 과장·최상급(최고·1위·100%·보장·무조건)·클릭 유도 금지.
|
|
19
20
|
- headlines: 짧은 제목 5개 — 각각 ${GOOGLE_LIMITS.headline}자(한글·전각은 2자로 셈 → 한글 15자) 이내. 첫 번째는 그 컨셉의 대표 문장.
|
|
20
21
|
- bodies: 본문 5개 — 60~120자. 구체적 혜택·근거·누구를 위한 것인지. 존댓말(해요체).
|
|
21
22
|
- descriptions: 설명 2개 — 각각 ${GOOGLE_LIMITS.description}자(한글 45자) 이내.
|
|
22
23
|
- imagePrompt: 배경 비주얼 생성 프롬프트(영어). 글자·로고·UI 절대 없음. 헤드라인을 올릴 넉넉한 여백. 브랜드색 ${brief.palette.primary}. 실존 인물·타사 로고 금지.
|
|
23
24
|
- theme: 어두운 배경(dark)이 어울리면 dark, 밝고 가벼우면 light.
|
|
24
|
-
- key: 영문 소문자 짧은 식별자(예 benefit·proof·urgency)
|
|
25
|
-
|
|
25
|
+
- key: 영문 소문자 짧은 식별자(예 benefit·proof·urgency). name: 컨셉 이름(brief.language · 2~6단어). angle: 이 컨셉이 노리는 각도 한 문장.
|
|
26
|
+
- 출력 JSON: {"concepts":[{key,name,angle,headlines[],bodies[],descriptions[],imagePrompt,theme,videoPrompt,videoLines[],videoVoice}]} — 모든 키를 빠짐없이.
|
|
27
|
+
- videoPrompt: 8~15초 짧은 광고 영상 한 컷 연출 프롬프트(영어 · 피사체·동작·카메라·조명·분위기 · 실존 인물·타사 로고·화면 글자 없음 · "No on-screen text, no subtitles, no logos" 로 끝낸다). 소리(잔잔한 BGM·효과음)도 한 줄 지시. 첫 프레임은 imagePrompt 의 비주얼에서 자연스럽게 움직여 나간다고 가정.
|
|
28
|
+
- videoLines: 영상에 얹을 화면 자막 2~3줄(brief.language · 각 18자 이내 · 훅 → 혜택 → 행동 순). videoVoice: 내레이션 한 문장(선택 · brief.language).${opts.requirements ? `\n광고주 요구사항(반드시 반영): 「${opts.requirements}」` : ''}${opts.referenceNote ? `\n참고 이미지: ${opts.referenceNote} — imagePrompt·videoPrompt 는 이 제품/인물이 주인공이 되게 쓴다("the product from the reference image" 처럼 지칭).` : ''}${opts.userCopy ? `\n광고주가 직접 쓴 문구(userCopy)가 있다 — 뜻·톤을 바꾸지 말고 규격(글자 수·정책)에만 맞춰 다듬어 headlines/bodies/descriptions 를 채운다. 컨셉은 그 문구를 나누어 담는다.` : ''}${opts.feedback ? `\n사용자 수정 요청: 「${opts.feedback}」 — 이 요청을 반영해 전부 다시 만든다.` : ''}`,
|
|
29
|
+
user: JSON.stringify({ brief, goal: opts.goal, cta, userCopy: opts.userCopy, previous: opts.previous?.map((c) => ({ key: c.key, name: c.name, angle: c.angle, headline: c.headlines[0] })) }),
|
|
26
30
|
});
|
|
27
|
-
|
|
31
|
+
// 언어 가드 — 시장 언어가 한국어가 아닌데 한글이 섞이면(요구사항·브리프가 한국어라 자주 생김) 한 번 더 그 언어로만 다시 쓴다
|
|
32
|
+
const lang = (brief.language || 'ko').split('-')[0];
|
|
33
|
+
const hasHangul = (t) => /[가-힣]/.test(t);
|
|
34
|
+
const violates = (cs) => lang !== 'ko' && cs.some((c) => [...c.headlines, ...c.bodies, ...c.descriptions, ...(c.videoLines || []), c.videoVoice || ''].some(hasHangul));
|
|
35
|
+
if (violates(out.concepts) && !opts.feedback?.includes('LANGUAGE_FIX')) {
|
|
36
|
+
const fixed = await generateConcepts(brief, { ...opts, feedback: `LANGUAGE_FIX: 직전 답에 한국어가 섞였어요. headlines·bodies·descriptions·videoLines·videoVoice·name·angle 전부 ${brief.language} 로만 다시 써 주세요(브리프·요구사항이 한국어여도 출력은 ${brief.language}).` });
|
|
37
|
+
return fixed;
|
|
38
|
+
}
|
|
39
|
+
const concepts = out.concepts.slice(0, opts.count).map((c) => ({ ...c, name: (c.name || c.headlines[0] || c.key).trim(), angle: (c.angle || c.descriptions[0] || c.headlines[0] || '').trim(), cta: cta.meta, headlines: c.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean).slice(0, 6), descriptions: c.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean).slice(0, 3), bodies: c.bodies.map((b) => b.trim()).filter(Boolean).slice(0, 6), videoLines: (c.videoLines || []).map((l) => l.trim()).filter(Boolean).slice(0, 3), videoVoice: c.videoVoice || undefined }));
|
|
28
40
|
return concepts;
|
|
29
41
|
}
|
|
30
42
|
function fallbackConcepts(brief, opts) {
|
|
31
43
|
const cta = ctaFor(opts.goal);
|
|
44
|
+
const uc = opts.userCopy;
|
|
45
|
+
if (uc && (uc.headlines?.length || uc.bodies?.length)) {
|
|
46
|
+
const heads = (uc.headlines || []).map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean);
|
|
47
|
+
const bodies = (uc.bodies || []).map((b) => b.trim()).filter(Boolean);
|
|
48
|
+
const descs = (uc.descriptions || []).map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean);
|
|
49
|
+
return [{ key: 'mine', name: '내 문구', angle: '광고주가 직접 쓴 문구', cta: cta.meta, theme: 'dark', headlines: heads.length ? heads : [fit(brief.company, GOOGLE_LIMITS.headline)], bodies: bodies.length ? bodies : [brief.offer], descriptions: descs.length ? descs : [fit(brief.offer, GOOGLE_LIMITS.description)], imagePrompt: `Abstract premium background in ${brief.palette.primary}, soft gradients, no text`, videoPrompt: `Slow cinematic push-in on an abstract premium gradient scene in ${brief.palette.primary}, soft light, gentle ambient music. No on-screen text, no subtitles, no logos.`, videoLines: heads.slice(0, 3) }];
|
|
50
|
+
}
|
|
32
51
|
const base = brief.usp.length ? brief.usp : [brief.offer];
|
|
33
52
|
return base.slice(0, opts.count).map((u, i) => ({
|
|
34
53
|
key: ['benefit', 'proof', 'urgency', 'compare', 'emotion'][i] || `c${i}`, name: `컨셉 ${i + 1}`, angle: u, cta: cta.meta, theme: 'dark',
|
|
@@ -36,6 +55,8 @@ function fallbackConcepts(brief, opts) {
|
|
|
36
55
|
bodies: [`${brief.offer} ${brief.company}에서 확인해 보세요.`, `${u}. ${brief.company}.`],
|
|
37
56
|
descriptions: [fit(brief.offer, GOOGLE_LIMITS.description)],
|
|
38
57
|
imagePrompt: `Abstract premium background in ${brief.palette.primary} and deep navy, soft gradients, no text, no logos, generous empty space`,
|
|
58
|
+
videoPrompt: `Slow cinematic camera drift over an abstract premium scene in ${brief.palette.primary} and deep navy, soft volumetric light, calm ambient music. No on-screen text, no subtitles, no logos.`,
|
|
59
|
+
videoLines: [fit(u, 18), fit(brief.offer, 18), '지금 확인해 보세요'].filter(Boolean),
|
|
39
60
|
}));
|
|
40
61
|
}
|
|
41
62
|
/** 매체별 카피 규격 검사 요약 */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Brief, Concept, CreativeAsset } from '../state.js';
|
|
2
|
+
import { type VideoJob, type VideoRatio } from './media.js';
|
|
3
|
+
export type VideoAsset = CreativeAsset & {
|
|
4
|
+
type: 'video';
|
|
5
|
+
costKrw: number;
|
|
6
|
+
durationSec: number;
|
|
7
|
+
mime: 'video/mp4';
|
|
8
|
+
};
|
|
9
|
+
export type FactoryLog = (s: string) => void;
|
|
10
|
+
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(기존 satori 트리 재사용) */
|
|
11
|
+
export declare function renderEndCard(dir: string, brief: Brief, concept: Concept, ratio: VideoRatio): Promise<string | null>;
|
|
12
|
+
/** 컨셉의 영상 소재 전부(계획된 비율·길이) — 실패한 건은 건너뛰고 로그 · 성공한 건마다 onAsset */
|
|
13
|
+
export declare function produceConceptVideos(o: {
|
|
14
|
+
dir: string;
|
|
15
|
+
brief: Brief;
|
|
16
|
+
concept: Concept;
|
|
17
|
+
jobs: VideoJob[];
|
|
18
|
+
backgrounds: Record<string, string | null>;
|
|
19
|
+
refs?: string[];
|
|
20
|
+
log?: FactoryLog;
|
|
21
|
+
onAsset?: (a: VideoAsset) => void | Promise<void>;
|
|
22
|
+
}): Promise<VideoAsset[]>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 소재 공장 — 컨셉 하나의 영상 소재를 끝까지 만든다: 첫 프레임(같은 비율의 생성 배경) → 영상 생성(Veo/Seedance · 네이티브 소리) → 자막 번인(헤드라인 훅·혜택·CTA) → 엔드카드(로고·CTA 2초) → 파생 비율(4:5·1:1 무료 크롭).
|
|
2
|
+
// CLI(ops.ts)와 웹(pipeline.ts)이 같은 함수를 부른다. 원가는 onAsset 으로 건별 통보(청구는 호출자 · ×1.1).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DERIVED_RATIOS, VIDEO_DIMS } from './media.js';
|
|
6
|
+
import { ctaLabel, renderConcept, renderOverlay } from './render.js';
|
|
7
|
+
import { generateVideo } from './video.js';
|
|
8
|
+
import { appendEndCard, burnCaptions, deriveRatio, hasFfmpeg, probe } from './videofx.js';
|
|
9
|
+
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(기존 satori 트리 재사용) */
|
|
10
|
+
export async function renderEndCard(dir, brief, concept, ratio) {
|
|
11
|
+
try {
|
|
12
|
+
const { w, h } = VIDEO_DIMS[ratio];
|
|
13
|
+
const out = await renderConcept(dir, brief, concept, {}, { force: false, only: [{ w, h, ratio, medium: 'meta', kind: 'poster' }] });
|
|
14
|
+
return out[0]?.file || null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** 컨셉의 영상 소재 전부(계획된 비율·길이) — 실패한 건은 건너뛰고 로그 · 성공한 건마다 onAsset */
|
|
21
|
+
export async function produceConceptVideos(o) {
|
|
22
|
+
const log = o.log || (() => { });
|
|
23
|
+
const c = o.concept;
|
|
24
|
+
const out = [];
|
|
25
|
+
const ff = await hasFfmpeg();
|
|
26
|
+
if (!ff)
|
|
27
|
+
log(' ⚠ ffmpeg 가 없어 자막·엔드카드·비율 파생 없이 원본 영상만 써요');
|
|
28
|
+
const cta = ctaLabel(c.cta, o.brief.language);
|
|
29
|
+
for (const job of o.jobs) {
|
|
30
|
+
const base = path.join(o.dir, `${c.key}_video_${job.ratio}`);
|
|
31
|
+
const finalFile = `${base}.mp4`;
|
|
32
|
+
if (fs.existsSync(finalFile)) {
|
|
33
|
+
const p = ff ? await probe(finalFile) : { durationSec: job.durationSec, w: VIDEO_DIMS[job.ratio].w, h: VIDEO_DIMS[job.ratio].h };
|
|
34
|
+
out.push({ concept: c.key, w: VIDEO_DIMS[job.ratio].w, h: VIDEO_DIMS[job.ratio].h, ratio: job.ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(p.durationSec), mime: 'video/mp4', costKrw: 0, origin: 'ai' });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const firstFrame = o.backgrounds[job.ratio] || (job.ratio === '9x16' ? o.backgrounds['4x5'] : job.ratio === '16x9' ? o.backgrounds['1x1'] : null) || undefined;
|
|
38
|
+
const prompt = `${c.videoPrompt || `A cinematic 8-second product/brand advertisement scene inspired by: ${c.imagePrompt}. Slow, elegant camera movement.`}${job.audio ? ' Subtle ambient background music and light sound design that fits the mood.' : ' Silent, no music, no sound.'} No on-screen text, no subtitles, no captions, no logos, no watermark.`;
|
|
39
|
+
log(`「${c.name}」 영상 ${job.ratio.replace('x', ':')} ${job.durationSec}초 만드는 중… (${job.provider === 'veo' ? 'Veo 3.1' : 'Seedance 2.5'} · 첫 프레임 ${firstFrame ? '생성 배경' : '없음'})`);
|
|
40
|
+
let raw;
|
|
41
|
+
let costKrw = 0;
|
|
42
|
+
let durationSec = job.durationSec;
|
|
43
|
+
try {
|
|
44
|
+
const r = await generateVideo({ file: `${base}.raw.mp4`, prompt, ratio: job.ratio, durationSec: job.durationSec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: firstFrame ? undefined : o.refs, negative: 'text, subtitles, captions, letters, watermark, logo, blurry, distorted faces, extra fingers', log });
|
|
45
|
+
raw = r.file;
|
|
46
|
+
costKrw = r.costKrw;
|
|
47
|
+
durationSec = r.durationSec;
|
|
48
|
+
log(` 영상 생성 완료 · 원가 ₩${Math.round(costKrw).toLocaleString()}`);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
const ck = Number(e.costKrw || 0);
|
|
52
|
+
log(` ❌ 영상 ${job.ratio} 실패 — ${e instanceof Error ? e.message.slice(0, 200) : e}${ck ? ` (원가 ₩${ck} 발생)` : ''}`);
|
|
53
|
+
if (ck > 0)
|
|
54
|
+
await o.onAsset?.({ concept: c.key, w: 0, h: 0, ratio: job.ratio, file: '', medium: 'meta', type: 'video', durationSec: 0, mime: 'video/mp4', costKrw: ck, origin: 'ai' });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let cur = raw;
|
|
58
|
+
if (ff) {
|
|
59
|
+
try {
|
|
60
|
+
if (job.captions && (c.videoLines?.length || c.headlines.length)) {
|
|
61
|
+
const lines = (c.videoLines?.length ? c.videoLines : [c.headlines[0], c.bodies[0]?.slice(0, 22) || '']).filter(Boolean).slice(0, 3);
|
|
62
|
+
const pr = await probe(cur);
|
|
63
|
+
const seg = pr.durationSec / lines.length;
|
|
64
|
+
const overlays = [];
|
|
65
|
+
for (const [i, t] of lines.entries())
|
|
66
|
+
overlays.push({ png: await renderOverlay(path.join(o.dir, `${c.key}_${job.ratio}_cap${i}.png`), pr.w, pr.h, t, { kind: 'line', primary: o.brief.palette.primary }), start: i * seg, end: (i + 1) * seg - (i === lines.length - 1 ? 0 : 0.12) });
|
|
67
|
+
overlays.push({ png: await renderOverlay(path.join(o.dir, `${c.key}_${job.ratio}_cta.png`), pr.w, pr.h, cta, { kind: 'cta', primary: o.brief.palette.primary }), start: Math.max(0, pr.durationSec - seg), end: pr.durationSec + 1 });
|
|
68
|
+
const withCap = `${base}.cap.mp4`;
|
|
69
|
+
await burnCaptions(cur, withCap, { overlays });
|
|
70
|
+
cur = withCap;
|
|
71
|
+
log(' 자막·행동 버튼 오버레이 ✓');
|
|
72
|
+
}
|
|
73
|
+
if (job.endCard) {
|
|
74
|
+
const card = await renderEndCard(o.dir, o.brief, c, job.ratio);
|
|
75
|
+
if (card) {
|
|
76
|
+
const withEnd = `${base}.end.mp4`;
|
|
77
|
+
await appendEndCard(cur, card, withEnd, 2);
|
|
78
|
+
cur = withEnd;
|
|
79
|
+
durationSec += 2;
|
|
80
|
+
log(' 엔드카드 ✓');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
log(` ⚠ 후처리 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 160) : e}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
fs.copyFileSync(cur, finalFile);
|
|
89
|
+
const dims = VIDEO_DIMS[job.ratio];
|
|
90
|
+
const asset = { concept: c.key, w: dims.w, h: dims.h, ratio: job.ratio, file: finalFile, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw, origin: 'ai' };
|
|
91
|
+
out.push(asset);
|
|
92
|
+
await o.onAsset?.(asset);
|
|
93
|
+
if (ff)
|
|
94
|
+
for (const dr of DERIVED_RATIOS[job.ratio]) {
|
|
95
|
+
if (o.jobs.some((j) => j.ratio === dr))
|
|
96
|
+
continue;
|
|
97
|
+
const df = path.join(o.dir, `${c.key}_video_${dr}.mp4`);
|
|
98
|
+
try {
|
|
99
|
+
await deriveRatio(finalFile, df, dr);
|
|
100
|
+
const d2 = VIDEO_DIMS[dr];
|
|
101
|
+
const a2 = { concept: c.key, w: d2.w, h: d2.h, ratio: dr, file: df, medium: 'meta', type: 'video', durationSec: Math.round(durationSec), mime: 'video/mp4', costKrw: 0, origin: 'ai' };
|
|
102
|
+
out.push(a2);
|
|
103
|
+
await o.onAsset?.(a2);
|
|
104
|
+
log(` ${dr.replace('x', ':')} 파생 ✓ (무료)`);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
log(` ⚠ ${dr} 파생 실패: ${e instanceof Error ? e.message.slice(0, 120) : e}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
@@ -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
|
|
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>;
|