adyou 0.4.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.
@@ -1,23 +1,47 @@
1
- // 소재 공장 — 컨셉 하나의 영상 소재를 끝까지 만든다: 첫 프레임(같은 비율의 생성 배경) 영상 생성(Veo/Seedance · 네이티브 소리) → 자막 번인(헤드라인 훅·혜택·CTA) → 엔드카드(로고·CTA 2초) → 파생 비율(4:5·1:1 무료 크롭).
2
- // CLI(ops.ts) (pipeline.ts)이 같은 함수를 부른다. 원가는 onAsset 으로 건별 통보(청구는 호출자 · ×1.1).
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).
3
5
  import fs from 'node:fs';
4
6
  import path from 'node:path';
5
7
  import { DERIVED_RATIOS, VIDEO_DIMS } from './media.js';
6
- import { ctaLabel, renderConcept, renderOverlay } from './render.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';
7
11
  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) {
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 = {}) {
11
15
  try {
12
16
  const { w, h } = VIDEO_DIMS[ratio];
13
- const out = await renderConcept(dir, brief, concept, {}, { force: false, only: [{ w, h, ratio, medium: 'meta', kind: 'poster' }] });
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' }] });
14
21
  return out[0]?.file || null;
15
22
  }
16
23
  catch {
17
24
  return null;
18
25
  }
19
26
  }
20
- /** 컨셉의 영상 소재 전부(계획된 비율·길이) 실패한 건은 건너뛰고 로그 · 성공한 건마다 onAsset */
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 */
21
45
  export async function produceConceptVideos(o) {
22
46
  const log = o.log || (() => { });
23
47
  const c = o.concept;
@@ -25,88 +49,135 @@ export async function produceConceptVideos(o) {
25
49
  const ff = await hasFfmpeg();
26
50
  if (!ff)
27
51
  log(' ⚠ ffmpeg 가 없어 자막·엔드카드·비율 파생 없이 원본 영상만 써요');
28
- const cta = ctaLabel(c.cta, o.brief.language);
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
+ }
29
70
  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;
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`);
41
76
  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(' 엔드카드 ✓');
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)`);
81
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;
82
115
  }
83
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
+ }
84
128
  catch (e) {
85
- log(` ⚠ 후처리 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 160) : e}`);
129
+ log(` ⚠ 이어붙이기 실패( 컷만 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
130
+ fs.copyFileSync(clipFiles[0], rawFile);
86
131
  }
87
132
  }
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}`);
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
+ }
108
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);
109
178
  }
179
+ }
180
+ log(` ${format} 완성 — 언어 ${langs.join('·')} × 비율 ${ratios.map((r) => r.replace('x', ':')).join('·')}${costKrw ? ` · 생성 원가 ₩${Math.round(costKrw).toLocaleString()}` : ''}`);
110
181
  }
111
182
  return out;
112
183
  }
@@ -1,4 +1,5 @@
1
1
  import type { Medium } from '../../adapters/types.js';
2
+ import { type Tone, type VideoFormat } from './playbook.js';
2
3
  export type CreativeMode = 'auto' | 'image' | 'video' | 'text';
3
4
  export type CreativeSource = 'ai' | 'guided' | 'manual';
4
5
  export type Tier = 'economy' | 'standard' | 'premium';
@@ -16,8 +17,11 @@ export type CreativeSpec = {
16
17
  tier?: Tier;
17
18
  variants?: number;
18
19
  };
20
+ /** 톤 다이얼(차분/활기/강하게) — 비우면 업종 플레이북 기본 */
21
+ tone?: Tone;
19
22
  video?: {
20
- count?: number;
23
+ count?: number; /** 포맷 지정(비우면 플레이북·장르·화면 유무로 자동) */
24
+ formats?: VideoFormat[];
21
25
  durationSec?: VideoDuration;
22
26
  ratios?: VideoRatio[];
23
27
  audio?: boolean;
@@ -43,12 +47,15 @@ export type VideoJob = {
43
47
  audio: boolean;
44
48
  captions: boolean;
45
49
  endCard: boolean;
46
- unitKrw: number;
50
+ unitKrw: number; /** 콘티 포맷(영상 1편 = 포맷 1개) */
51
+ format?: VideoFormat;
47
52
  };
48
53
  export type CreativePlan = {
49
54
  mode: CreativeMode;
50
55
  source: CreativeSource;
51
56
  concepts: number;
57
+ tone: Tone;
58
+ formats: VideoFormat[];
52
59
  images: {
53
60
  enabled: boolean;
54
61
  tier: Tier;
@@ -99,6 +106,9 @@ export declare const SOURCE_LABEL: Record<CreativeSource, string>;
99
106
  export declare function resolveCreativePlan(spec: CreativeSpec | undefined, ctx: {
100
107
  monthlyKrw: number;
101
108
  media: Medium[];
109
+ industry?: string | null;
110
+ genres?: string[];
111
+ hasScreens?: boolean;
102
112
  }): CreativePlan;
103
113
  /** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
104
114
  export declare const DERIVED_RATIOS: Record<VideoRatio, VideoRatio[]>;
@@ -1,3 +1,4 @@
1
+ import { pickFormats, playbookFor } from './playbook.js';
1
2
  /** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
2
3
  export const CREATIVE_MARKUP = 1.1;
3
4
  export const chargeFor = (costKrw) => Math.ceil(Math.max(0, costKrw) * CREATIVE_MARKUP);
@@ -26,6 +27,7 @@ export function pickVideoModel(durationSec, tier, provider = 'auto', ratio = '9x
26
27
  return VEO_BY_TIER[tier];
27
28
  return 'dreamina-seedance-2-5-260628';
28
29
  }
30
+ const FORMAT_LABEL_SHORT = { ugc_selfie: '셀카 후기', ui_demo: '화면 데모', cinematic: '시네마틱', product_hero: '제품', lifestyle: '라이프스타일', text_hook: '글자 훅' };
29
31
  export const MODE_LABEL = { auto: '알아서(추천)', image: '이미지 위주', video: '영상 위주', text: '글·링크 위주' };
30
32
  export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구사항·참고 이미지로', manual: '직접 올린 소재로' };
31
33
  /**
@@ -47,9 +49,14 @@ export function resolveCreativePlan(spec, ctx) {
47
49
  const ratios = ['1x1', '4x5', '9x16', '16x9'];
48
50
  const vTier = s.video?.tier || (ctx.monthlyKrw >= 2_000_000 ? 'standard' : 'economy');
49
51
  const duration = s.video?.durationSec || 8;
50
- const wantVideo = source !== 'manual' && (mode === 'auto' || mode === 'video' || (s.video?.count || 0) > 0) && mode !== 'text' && mode !== 'image';
51
- const vRatios = s.video?.ratios?.length ? s.video.ratios : mode === 'video' ? ['9x16', '16x9'] : ['9x16'];
52
- const count = wantVideo ? Math.max(0, Math.min(4, s.video?.count ?? vRatios.length)) : 0;
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;
53
60
  const resolution = s.video?.resolution || '720p';
54
61
  const perConcept = [];
55
62
  for (let i = 0; i < count; i++) {
@@ -57,7 +64,9 @@ export function resolveCreativePlan(spec, ctx) {
57
64
  const model = pickVideoModel(duration, vTier, s.video?.provider || 'auto', ratio);
58
65
  const m = VIDEO_MODELS[model];
59
66
  const sec = Math.min(duration, m.maxSec);
60
- perConcept.push({ ratio, durationSec: sec, provider: m.provider, model, resolution, audio: s.video?.audio ?? true, captions: s.video?.captions ?? true, endCard: s.video?.endCard ?? true, unitKrw: Math.round(m.perSecKrw[resolution] * sec) });
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] });
61
70
  }
62
71
  const conceptsWithVideo = count ? (small && mode !== 'video' ? 1 : concepts) : 0;
63
72
  const img = IMAGE_MODELS[imgTier];
@@ -72,10 +81,10 @@ export function resolveCreativePlan(spec, ctx) {
72
81
  else
73
82
  lines.push('이미지 생성 없이 브랜드 색 배경 + 문구로 만들어요(비용 0)');
74
83
  if (conceptsWithVideo)
75
- lines.push(`영상 ${conceptsWithVideo * perConcept.length}(방향 ${conceptsWithVideo} × ${perConcept.map((v) => `${v.ratio.replace('x', ':')} ${v.durationSec}초`).join('·')}) ≈ ₩${videosKrw.toLocaleString()}${perConcept.some((v) => v.audio) ? ' · 소리 포함' : ''}`);
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 파생 무료`);
76
85
  lines.push(`예상 원가 ₩${totalKrw.toLocaleString()} → 청구 ₩${chargeFor(totalKrw).toLocaleString()}(원가의 1.1배 · 실제 생성된 것만, 만들어진 뒤 정확한 원가로 차감)`);
77
- const explain = [`소재 형태: ${MODE_LABEL[mode]} · 출처: ${SOURCE_LABEL[source]}`, ...lines].join('\n');
78
- return { mode, source, concepts, images: { enabled: imagesEnabled, tier: imgTier, model: img.model, variants, ratios, unitKrw: img.unitKrw }, videos: { perConcept, conceptsWithVideo }, estimate: { imagesKrw, videosKrw, totalKrw, chargedKrw: chargeFor(totalKrw), lines }, explain };
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 };
79
88
  }
80
89
  /** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
81
90
  export const DERIVED_RATIOS = { '9x16': ['4x5', '1x1'], '16x9': ['1x1'], '1x1': [], '4x5': [] };
@@ -0,0 +1,40 @@
1
+ export type VideoFormat = 'ugc_selfie' | 'ui_demo' | 'cinematic' | 'product_hero' | 'lifestyle' | 'text_hook';
2
+ export declare const FORMAT_LABEL: Record<VideoFormat, string>;
3
+ export declare const FORMAT_DESC: Record<VideoFormat, string>;
4
+ export type Tone = 'calm' | 'lively' | 'bold';
5
+ export declare const TONE_LABEL: Record<Tone, string>;
6
+ export declare const TONE_WORDS: Record<Tone, {
7
+ camera: string;
8
+ music: string;
9
+ caption: string;
10
+ }>;
11
+ export type Playbook = {
12
+ key: string;
13
+ label: string;
14
+ /** 우선순위 포맷(앞이 먼저) */
15
+ formats: VideoFormat[];
16
+ /** 훅 패턴(콘티 LLM 에 예시로) */
17
+ hooks: string[];
18
+ /** 기본 비주얼 장르 */
19
+ genres: string[];
20
+ /** 꼭 보여줄 것 */
21
+ musts: string[];
22
+ /** 피할 것(정책·상식) */
23
+ avoid: string[];
24
+ /** 기본 톤 */
25
+ tone: Tone;
26
+ };
27
+ export declare const INDUSTRIES: Record<string, Playbook>;
28
+ export declare const INDUSTRY_KEYS: string[];
29
+ export declare function playbookFor(industry?: string | null): Playbook;
30
+ /** 이 브리프에 맞는 영상 포맷 n개 — 플레이북 순서를 따르되 장르(anime 등)·화면 유무를 반영 · 항상 서로 다른 포맷 */
31
+ export declare function pickFormats(o: {
32
+ industry?: string | null;
33
+ genres?: string[];
34
+ hasScreens: boolean;
35
+ count: number;
36
+ requested?: VideoFormat[];
37
+ }): VideoFormat[];
38
+ /** 장르 → 이미지/영상 프롬프트 스타일 토큰 */
39
+ export declare const GENRE_STYLE: Record<string, string>;
40
+ export declare const genreStyle: (g?: string) => string;
@@ -0,0 +1,61 @@
1
+ export const FORMAT_LABEL = { ugc_selfie: '셀카 후기(UGC)', ui_demo: '화면 데모', cinematic: '시네마틱', product_hero: '제품 히어로', lifestyle: '라이프스타일', text_hook: '큰 글자 훅' };
2
+ export const FORMAT_DESC = {
3
+ ugc_selfie: '실제 고객처럼 보이는 인물이 셀카로 한마디 — 네이티브 톤, 소비재·앱에서 CPM 낮고 CTR 높음',
4
+ ui_demo: '실제 사이트/앱 화면이 폰 안에서 움직임 — 앱·SaaS·쇼핑몰에서 「무엇인지」를 3초 안에 보여줌(생성 비용 0)',
5
+ cinematic: '장르(실사·애니·3D)에 맞는 연출 컷 2개(훅 → 보상) — 브랜드 무드·세계관',
6
+ product_hero: '제품/결과물이 주인공인 클로즈업·회전·질감 — 뷰티·식품·패션',
7
+ lifestyle: '타겟이 제품을 쓰는 장면 — 여행·피트니스·가전',
8
+ text_hook: '큰 글자 질문/숫자로 시작해 증거로 이어가는 텍스트 중심 — B2B·교육·서비스',
9
+ };
10
+ export const TONE_LABEL = { calm: '차분하게', lively: '활기차게', bold: '강하게' };
11
+ export const TONE_WORDS = {
12
+ calm: { camera: 'smooth, steady camera, gentle motion, soft natural light', music: 'soft, warm, understated background music', caption: '차분한 서술형' },
13
+ lively: { camera: 'energetic handheld feel, quick push-ins, dynamic motion in the first second, bright lighting', music: 'upbeat, modern, rhythmic music with a clear beat', caption: '짧고 리듬 있는 구어체' },
14
+ bold: { camera: 'punchy fast cuts, dramatic close-ups, strong contrast, high energy from frame one', music: 'driving, bold, high-energy music with impact hits', caption: '짧고 강한 선언형' },
15
+ };
16
+ const P = (key, label, formats, hooks, genres, musts, avoid, tone = 'lively') => ({ key, label, formats, hooks, genres, musts, avoid, tone });
17
+ export const INDUSTRIES = {
18
+ app_game: P('app_game', '앱·게임·엔터·웹툰', ['ugc_selfie', 'ui_demo', 'cinematic'], ['「이거 해봤어?」 반응 셀카', '캐릭터/세계관 컷 → 실제 화면', '선택지 두 개를 보여주고 결말 예고'], ['anime', 'ugc', '3d'], ['실제 앱 화면 3초 안에', '재미·몰입의 순간'], ['현금·보상 과장', '성인 암시', '타사 IP·실존 인물'], 'lively'),
19
+ saas: P('saas', 'SaaS·업무 도구', ['ui_demo', 'text_hook', 'ugc_selfie'], ['「아직 이걸 손으로 하세요?」', '숫자 훅(「하루 3시간 절약」은 근거 있을 때만)', '전/후 업무 화면(건강 아님·허용)'], ['photoreal', 'motion_graphics', 'ugc'], ['핵심 화면·결과물', '누구를 위한 도구인지'], ['검증 없는 수치', '경쟁사 비방'], 'lively'),
20
+ ecommerce_beauty: P('ecommerce_beauty', '화장품·뷰티', ['ugc_selfie', 'product_hero', 'lifestyle'], ['텍스처·발림 클로즈업', '「이거 하나로」 루틴 셀카', '성분 하나 강조'], ['photoreal', 'product_shot', 'ugc'], ['제품 질감·사용 장면', '피부 톤 다양'], ['전후 비교 사진', '효능 단정(「완치」「100%」)', '신체 부위 클로즈업 과다'], 'lively'),
21
+ ecommerce_health: P('ecommerce_health', '건강기능식품·헬스 보조', ['ugc_selfie', 'product_hero', 'text_hook'], ['「아침에 이거 하나」 루틴', '성분·인증 마크(실제 있는 것만)', '「이런 분께」 타겟 지칭 없이 상황으로'], ['photoreal', 'product_shot', 'ugc'], ['제품·성분·인증(사이트 근거)', '복용 장면'], ['질병 치료·예방 표현', '전후 비교', '개인 건강상태 지칭(「당신은 비만」)', '의사 연출'], 'calm'),
22
+ ecommerce_fashion: P('ecommerce_fashion', '패션·잡화', ['lifestyle', 'product_hero', 'ugc_selfie'], ['핏·움직임(걷기·돌기)', '코디 3가지 빠른 컷', '가격/한정(사이트에 있으면)'], ['photoreal', 'ugc'], ['착용 실루엣', '소재·디테일'], ['비현실적 몸매 묘사', '타사 로고'], 'lively'),
23
+ ecommerce_food: P('ecommerce_food', '식품·음료·농산물', ['product_hero', 'ugc_selfie', 'lifestyle'], ['한 입·시즐(김·소리)', '산지/생산 장면 → 식탁', '「오늘 주문 → 내일 도착」(사이트 근거)'], ['photoreal', 'product_shot', 'ugc'], ['먹는 장면·질감', '원산지·신선함'], ['건강 효능 단정', '가격 오표기'], 'lively'),
24
+ education: P('education', '교육·강의·학원', ['text_hook', 'ugc_selfie', 'ui_demo'], ['「이 문제 풀 수 있어요?」', '수강생 셀카 후기(합격·성과는 근거 있을 때만)', '커리큘럼 첫 장면'], ['photoreal', 'motion_graphics', 'ugc'], ['무엇을 배우는지 3초', '강사/교재 실제 화면'], ['합격 보장', '타 기관 비교'], 'lively'),
25
+ travel: P('travel', '여행·숙박·투어', ['lifestyle', 'cinematic', 'ugc_selfie'], ['목적지 첫 장면 3개 빠른 컷', '「이 가격에?」(실제 가격만)', '여행자 셀카 리액션'], ['photoreal', 'ugc'], ['목적지·숙소·경험 장면', '가격·기간(사이트 근거)'], ['가짜 「마감 임박」', '타사 항공·호텔 로고'], 'lively'),
26
+ airline: P('airline', '항공', ['cinematic', 'lifestyle', 'text_hook'], ['이륙/창밖 → 도착지', '좌석·서비스 디테일', '「○○행 ○○원부터」(실제 운임만)'], ['photoreal'], ['노선·좌석·서비스', '운임 조건'], ['안전 관련 과장', '경쟁사 언급'], 'calm'),
27
+ realestate_construction: P('realestate_construction', '건설·부동산·인테리어', ['cinematic', 'text_hook', 'lifestyle'], ['완공 드론 → 내부 워크스루', '「이 동네에 이런 집?」', '시공 전→후(주거 허용·정직하게)'], ['photoreal', '3d'], ['실제 조감·평면·위치', '시공 품질'], ['수익률·시세 상승 단정', '허위 분양 조건'], 'calm'),
28
+ finance: P('finance', '금융·보험·투자', ['text_hook', 'ugc_selfie'], ['「매달 이만큼 새나가요」(근거 있을 때만)', '한 줄 질문'], ['photoreal', 'motion_graphics'], ['상품 조건·수수료'], ['수익 보장', '공포 소구'], 'calm'),
29
+ medical: P('medical', '의료·병원·시술', ['text_hook', 'cinematic'], ['시설·의료진(실존 인물 초상 동의 필요)'], ['photoreal'], ['진료 과목·위치'], ['전후 비교', '치료 효과 단정', '환자 후기 연출'], 'calm'),
30
+ b2b_service: P('b2b_service', 'B2B 서비스·컨설팅·제조', ['text_hook', 'ui_demo', 'cinematic'], ['「담당자님, 이거 아직도?」', '숫자 훅(근거)', '결과물 실물/화면'], ['photoreal', 'motion_graphics'], ['누구의 어떤 문제를 푸는지', '결과물·레퍼런스(공개 가능한 것)'], ['고객사 로고 무단 사용'], 'calm'),
31
+ local_service: P('local_service', '지역 서비스(음식점·미용·학원·병원 외)', ['ugc_selfie', 'lifestyle', 'product_hero'], ['매장 첫 장면 + 대표 메뉴/서비스', '사장님 한마디', '「○○역 3분」(사이트 근거)'], ['photoreal', 'ugc'], ['실제 매장·서비스 장면', '위치·영업시간'], ['가짜 후기'], 'lively'),
32
+ automotive: P('automotive', '자동차·모빌리티', ['cinematic', 'product_hero', 'lifestyle'], ['주행 첫 컷 → 디테일', '「월 ○○원」(실제 조건)'], ['photoreal', '3d'], ['외관·실내·주행'], ['안전 과장', '경쟁 모델 비방'], 'bold'),
33
+ fitness: P('fitness', '피트니스·운동·다이어트', ['ugc_selfie', 'lifestyle', 'text_hook'], ['운동 동작 첫 컷', '루틴 셀카', '「하루 10분」(프로그램 근거)'], ['photoreal', 'ugc'], ['운동 장면·프로그램'], ['전후 몸매 비교', '체중 감량 단정', '신체 수치 지칭'], 'bold'),
34
+ other: P('other', '기타', ['ugc_selfie', 'cinematic', 'text_hook'], ['질문 훅', '결과물 첫 장면', '한 문장 약속(근거)'], ['photoreal', 'ugc'], ['무엇을 파는지 3초 안에'], ['과장·최상급'], 'lively'),
35
+ };
36
+ export const INDUSTRY_KEYS = Object.keys(INDUSTRIES);
37
+ export function playbookFor(industry) { return INDUSTRIES[industry || ''] || INDUSTRIES.other; }
38
+ /** 이 브리프에 맞는 영상 포맷 n개 — 플레이북 순서를 따르되 장르(anime 등)·화면 유무를 반영 · 항상 서로 다른 포맷 */
39
+ export function pickFormats(o) {
40
+ if (o.requested?.length)
41
+ return [...new Set(o.requested)].slice(0, Math.max(1, o.count));
42
+ const pb = playbookFor(o.industry);
43
+ let order = [...pb.formats, 'cinematic', 'ugc_selfie', 'text_hook', 'product_hero', 'lifestyle'];
44
+ if (!o.hasScreens)
45
+ order = order.filter((f) => f !== 'ui_demo');
46
+ const anime = (o.genres || []).includes('anime') || (o.genres || []).includes('3d');
47
+ if (anime && !order.slice(0, 2).includes('cinematic'))
48
+ order = ['cinematic', ...order.filter((f) => f !== 'cinematic')];
49
+ return [...new Set(order)].slice(0, Math.max(1, o.count));
50
+ }
51
+ /** 장르 → 이미지/영상 프롬프트 스타일 토큰 */
52
+ export const GENRE_STYLE = {
53
+ photoreal: 'photorealistic, natural skin and materials, shot on a modern mirrorless camera, realistic lighting',
54
+ ugc: 'authentic user-generated smartphone footage look, handheld, natural indoor light, real-person casual vibe, no studio polish',
55
+ anime: 'high-quality Japanese anime style, clean linework, vivid cel shading, dramatic sky and light, studio-quality animation',
56
+ '3d': 'stylized 3D render, soft global illumination, Pixar-like character appeal, clean materials',
57
+ illustration: 'flat editorial illustration, bold shapes, limited palette, tasteful texture',
58
+ motion_graphics: 'kinetic typography and motion graphics, clean vector shapes, brand colors, smooth easing',
59
+ product_shot: 'studio product photography, macro detail, controlled lighting, clean backdrop',
60
+ };
61
+ export const genreStyle = (g) => GENRE_STYLE[g || ''] || GENRE_STYLE.photoreal;
@@ -1,6 +1,7 @@
1
1
  import type { Brief, Concept, CreativeAsset } from '../state.js';
2
2
  import { type Size } from './specs.js';
3
- export declare function loadFonts(): Promise<{
3
+ export declare const FONT_FAMILY = "Pretendard, PretendardJP, NotoCJKtc, NotoCJKsc, NotoThai";
4
+ export declare function loadFonts(lang?: string): Promise<{
4
5
  name: string;
5
6
  data: ArrayBuffer;
6
7
  weight: 400 | 500 | 700 | 800;
@@ -28,6 +29,18 @@ export declare function renderConcept(dir: string, brief: Brief, concept: Concep
28
29
  export declare function renderLogo(dir: string, brief: Brief): Promise<CreativeAsset[]>;
29
30
  /** 영상 자막 오버레이 — 투명 PNG(영상 크기) 에 하단 자막 알약 또는 CTA 버튼. ffmpeg drawtext(폰트 빌드 의존) 대신 satori 로 그린다. */
30
31
  export declare function renderOverlay(file: string, w: number, hh: number, text: string, o: {
31
- kind: 'line' | 'cta';
32
+ kind: 'line' | 'cta' | 'hook';
32
33
  primary: string;
34
+ lang?: string;
33
35
  }): Promise<string>;
36
+ /** UI 데모용 배경(브랜드 그래디언트 · 영상 크기) + 폰 프레임(투명 화면 영역) — ffmpeg 가 스크린샷을 그 사이에 끼운다 */
37
+ export declare function renderPhoneStage(dir: string, w: number, hh: number, primary: string, dark: string): Promise<{
38
+ bg: string;
39
+ frame: string;
40
+ screen: {
41
+ x: number;
42
+ y: number;
43
+ w: number;
44
+ h: number;
45
+ };
46
+ }>;