adyou 0.6.2 → 0.6.3
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/core/creatives/factory.js +37 -10
- package/dist/core/creatives/playbook.js +2 -1
- package/dist/core/creatives/qa.d.ts +7 -2
- package/dist/core/creatives/qa.js +19 -9
- package/dist/core/creatives/render.d.ts +2 -0
- package/dist/core/creatives/render.js +11 -5
- package/dist/core/creatives/storyboard.js +1 -0
- package/dist/core/creatives/videofx.d.ts +2 -0
- package/dist/core/creatives/videofx.js +15 -0
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ import { genreStyle, TONE_WORDS } from './playbook.js';
|
|
|
9
9
|
import { ctaLabel, renderConcept, renderOverlay, renderPhoneStage } from './render.js';
|
|
10
10
|
import { langName, localizedBoard, localizedConcept, planStoryboards, translateCopy } from './storyboard.js';
|
|
11
11
|
import { generateVideo } from './video.js';
|
|
12
|
-
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, mixVoiceover, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
|
|
12
|
+
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, mixVoiceover, motionScore, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
|
|
13
13
|
import { synthesizeSpeech, ttsAvailable } from './tts.js';
|
|
14
14
|
import { isMockGen } from './images.js';
|
|
15
15
|
import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
|
|
@@ -74,6 +74,17 @@ export async function produceConceptVideos(o) {
|
|
|
74
74
|
log(` ${extra.map(langName).join('·')} 로 문구·자막 옮기는 중…`);
|
|
75
75
|
c.i18n = { ...(c.i18n || {}), ...(await translateCopy(o.brief, c, c.storyboards, extra)) };
|
|
76
76
|
}
|
|
77
|
+
// 제품 사진(광고주 업로드 또는 실물 제품으로 분류된 사이트 이미지)이 없으면 「제품 히어로」는 상상 제품이 되므로 라이프스타일로 대체(콘티도 그 포맷으로)
|
|
78
|
+
const hasProductPhoto = !!(o.refs && o.refs.length);
|
|
79
|
+
if (!hasProductPhoto && o.jobs.some((j) => j.format === 'product_hero')) {
|
|
80
|
+
for (const j of o.jobs)
|
|
81
|
+
if (j.format === 'product_hero')
|
|
82
|
+
j.format = o.jobs.some((x) => x.format === 'lifestyle') ? 'cinematic' : 'lifestyle';
|
|
83
|
+
log(' 제품 사진이 없어 「제품 히어로」 대신 장면 포맷(라이프스타일/시네마틱)으로 바꿨어요');
|
|
84
|
+
const need = [...new Set(o.jobs.map((j) => j.format || 'cinematic'))];
|
|
85
|
+
if (need.some((f) => !c.storyboards.some((b) => b.format === f)))
|
|
86
|
+
c.storyboards = await planStoryboards(o.brief, c, { formats: need, durationSec: o.jobs[0]?.durationSec || 8, tone, goal: o.goal || 'traffic', hasScreens, log });
|
|
87
|
+
}
|
|
77
88
|
for (const job of o.jobs) {
|
|
78
89
|
const format = (job.format || 'cinematic');
|
|
79
90
|
const board = c.storyboards.find((b) => b.format === format) || c.storyboards[0];
|
|
@@ -115,16 +126,16 @@ export async function produceConceptVideos(o) {
|
|
|
115
126
|
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
116
127
|
const speech = line && mode === 'native' ? ` The person speaks to camera in ${langName(rl)} (spoken language must be ${langName(rl)}): "${line}". Natural lip sync, conversational, genuine, native-speaker accent. The line is short and finishes comfortably by second ${Math.max(3, clip.sec - 2)}; for the final 1.5 seconds the person is silent, smiling or nodding at the camera (no speech cut-off).` : line ? ` The person does NOT speak at all — lips stay closed or in a natural smile; they react to the camera (nod, raised eyebrows, showing the product or their phone screen, a small laugh) like a silent vlog moment; a narrator voice-over will be added later. Audio: light background music only, no speech, no dialogue, no humming.` : '';
|
|
117
128
|
const prompt = `${clip.prompt}${speech} Style: ${genreStyle(board.genre)}. Camera: ${tw.camera}. Audio: ${job.audio ? `${board.music || tw.music}${person && mode === 'native' ? ', clear voice over the music' : ''}` : 'silent'}. No on-screen text, no subtitles, no captions, no logos, no watermark.`;
|
|
118
|
-
// 첫
|
|
119
|
-
let firstFrame
|
|
120
|
-
const refImg =
|
|
129
|
+
// 첫 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) — 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 첫 프레임으로. 그 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
|
|
130
|
+
let firstFrame;
|
|
131
|
+
const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
|
|
121
132
|
if (refImg && ci === 0 && ff) {
|
|
122
133
|
try {
|
|
123
134
|
const stage = await renderPhoneStage(o.dir, dims.w, dims.h, o.brief.palette.primary, o.brief.palette.dark);
|
|
124
135
|
firstFrame = await refCanvas(refImg, stage.bg, path.join(o.dir, `${key}_${rl}_refframe.png`), dims);
|
|
125
|
-
log(
|
|
136
|
+
log(` 첫 프레임 = 제품 사진(${path.basename(refImg)})`);
|
|
126
137
|
}
|
|
127
|
-
catch { /*
|
|
138
|
+
catch { /* 없이 진행 */ }
|
|
128
139
|
}
|
|
129
140
|
// 말하는 클립의 모델 — 영어 아닌 언어는 Seedance 네이티브 발화(립싱크) · 영어는 Veo
|
|
130
141
|
const clipModel = line && mode === 'native' ? speechVideoModel(rl, job.model) : job.model;
|
|
@@ -178,6 +189,21 @@ export async function produceConceptVideos(o) {
|
|
|
178
189
|
}
|
|
179
190
|
}
|
|
180
191
|
}
|
|
192
|
+
// QA ②-0 움직임: 사실상 정지 화면이면 첫 프레임 없이 「역동적 카메라」로 1회 재생성(원가 추가)
|
|
193
|
+
if (ff && !isMockGen()) {
|
|
194
|
+
const mo = await motionScore(cf);
|
|
195
|
+
if (mo < 3) {
|
|
196
|
+
log(` 움직임이 거의 없어요(점수 ${mo.toFixed(1)}) → 장면을 다시 만들어요`);
|
|
197
|
+
try {
|
|
198
|
+
const r3 = await generateVideo({ file: cf, prompt: `${prompt} IMPORTANT: continuous visible motion throughout — moving subject, camera push-in or pan, changing light; never a static image.`, ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, negative: 'static image, still frame, slideshow, text, subtitles, watermark, logo', log });
|
|
199
|
+
costKrw += r3.costKrw;
|
|
200
|
+
log(` 다시 만든 컷 움직임 ${(await motionScore(cf)).toFixed(1)}`);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
log(` ⚠ 재생성 실패(원본 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
181
207
|
// QA ② 참고 제품 사진이 있으면 프레임의 제품이 같은지 → 다르면 사진 켄번즈(생성 위험 0)로 교체
|
|
182
208
|
if (refImg && ff && !isMockGen()) {
|
|
183
209
|
const q = await checkProductMatch(cf, refImg);
|
|
@@ -223,16 +249,17 @@ export async function produceConceptVideos(o) {
|
|
|
223
249
|
log(` ⚠ 이어붙이기 실패(첫 컷만 사용): ${e instanceof Error ? e.message.slice(0, 120) : e}`);
|
|
224
250
|
fs.copyFileSync(clipFiles[0], rawFile);
|
|
225
251
|
}
|
|
226
|
-
// TTS
|
|
227
|
-
const
|
|
228
|
-
|
|
252
|
+
// 내레이션 — ① 말하는 포맷인데 TTS 모드(Seedance 없음) ② 사람 없는 포맷(제품·시네마틱·라이프스타일·글자 훅)은 콘티 voice 를 TTS 로(무음·음악만인 영상 방지 · 2026-09-17)
|
|
253
|
+
const speakingBoard = board.clips.some((cl) => cl.kind === 'gen' && cl.speech);
|
|
254
|
+
const ttsLine = speakingBoard ? (speechModeFor(rl) === 'tts' ? (rl === board.lang ? board.clips.find((cl) => cl.speech).speech : lb0.speech || lb0.voice) : undefined) : (lb0.voice || board.voice);
|
|
255
|
+
if (ttsLine && ttsAvailable() && ff && !isMockGen()) {
|
|
229
256
|
try {
|
|
230
257
|
// 🔴 Veo 는 「대사 없이」 라고 해도 웅얼거리는 말을 넣는다(2026-09-16 실측 · TTS 와 겹쳐 들림) → 원본에 말소리가 있으면 원본 소리를 버리고 TTS 만, 음악만이면 낮춰서 깐다
|
|
231
258
|
const sp = await checkSpeech(rawFile, { lang: rl, expected: '' });
|
|
232
259
|
const hasSpeech = !sp.skipped && sp.data?.lang !== 'none';
|
|
233
260
|
const v = await synthesizeSpeech({ text: ttsLine, lang: rl, file: path.join(o.dir, `${key}_${rl}_vo.mp3`), tone, persona: o.brief.audienceProfile?.persona });
|
|
234
261
|
const mixed = rawFile.replace(/\.raw\.mp4$/, '.vo.raw.mp4');
|
|
235
|
-
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : 0.3 });
|
|
262
|
+
await mixVoiceover(rawFile, v.file, mixed, { duck: hasSpeech ? 0 : speakingBoard ? 0.3 : 0.4 });
|
|
236
263
|
fs.copyFileSync(mixed, rawFile);
|
|
237
264
|
costByLang[rl] = (costByLang[rl] || 0) + v.costKrw;
|
|
238
265
|
log(` ${langName(rl)} 내레이션(TTS) ${v.durationSec.toFixed(1)}초 얹음 ✓${hasSpeech ? ' (원본 말소리 감지 → 원본 소리 제거)' : ' (원본 음악 유지)'}`);
|
|
@@ -55,7 +55,8 @@ export const GENRE_STYLE = {
|
|
|
55
55
|
anime: 'high-quality Japanese anime style, clean linework, vivid cel shading, dramatic sky and light, studio-quality animation',
|
|
56
56
|
'3d': 'stylized 3D render, soft global illumination, Pixar-like character appeal, clean materials',
|
|
57
57
|
illustration: 'flat editorial illustration, bold shapes, limited palette, tasteful texture',
|
|
58
|
-
|
|
58
|
+
// 🔴 AI 가 그리는 글자·숫자는 깨진다(SaaS 글자 훅에 의미 없는 숫자 · 2026-09-17) → 모션그래픽은 추상 도형만, 글자는 우리 자막이 담당
|
|
59
|
+
motion_graphics: 'abstract motion graphics with clean vector shapes, flowing lines and brand-color gradients, smooth easing — strictly NO letters, NO numbers, NO words, NO UI, NO charts with labels',
|
|
59
60
|
product_shot: 'studio product photography, macro detail, controlled lighting, clean backdrop',
|
|
60
61
|
};
|
|
61
62
|
export const genreStyle = (g) => GENRE_STYLE[g || ''] || GENRE_STYLE.photoreal;
|
|
@@ -16,7 +16,12 @@ export declare function checkSpeech(video: string, o: {
|
|
|
16
16
|
export declare function checkProductMatch(video: string, refImage: string): Promise<QaResult>;
|
|
17
17
|
/** 프레임 위생 — 길이·크기·검은 화면 비율 */
|
|
18
18
|
export declare function checkFrames(video: string, minSec?: number): Promise<QaResult>;
|
|
19
|
-
/**
|
|
19
|
+
/** 사이트 자동 수집 이미지 분류 — 실물 제품 사진만 참고로 쓴다. 사람(초상권) · 로고/배너/글자 그래픽(2026-09-16 실사고: og:image 빨간 로고 상자가 첫 프레임이 됨) 제외 */
|
|
20
|
+
export declare function classifyRefImage(image: string): Promise<{
|
|
21
|
+
person: boolean;
|
|
22
|
+
product: boolean;
|
|
23
|
+
graphic: boolean;
|
|
24
|
+
} | null>;
|
|
20
25
|
export declare function containsPerson(image: string): Promise<boolean | null>;
|
|
21
|
-
/** 자동 수집 이미지 중
|
|
26
|
+
/** 자동 수집 이미지 중 「사람 없음 · 실물 제품 · 그래픽 아님」만(판정 불가면 제외 — 보수적) */
|
|
22
27
|
export declare function filterProductRefs(files: string[], log?: (s: string) => void): Promise<string[]>;
|
|
@@ -120,29 +120,39 @@ export async function checkFrames(video, minSec = 2) {
|
|
|
120
120
|
return { ok: true, skipped: true, note: `프레임 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 60) : e})` };
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
-
/**
|
|
124
|
-
export async function
|
|
123
|
+
/** 사이트 자동 수집 이미지 분류 — 실물 제품 사진만 참고로 쓴다. 사람(초상권) · 로고/배너/글자 그래픽(2026-09-16 실사고: og:image 빨간 로고 상자가 첫 프레임이 됨) 제외 */
|
|
124
|
+
export async function classifyRefImage(image) {
|
|
125
125
|
const cfg = imageConfig();
|
|
126
126
|
if (!cfg || cfg.provider !== 'bizrouter')
|
|
127
127
|
return null;
|
|
128
128
|
try {
|
|
129
|
-
const res = await fetch(`${cfg.base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'bizrouter/qwen-3.8-flash', max_tokens:
|
|
129
|
+
const res = await fetch(`${cfg.base}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model: 'bizrouter/qwen-3.8-flash', max_tokens: 80, messages: [{ role: 'user', content: [{ type: 'text', text: 'Classify this image for use as an ad reference. Answer JSON only: {"person": true if a real human person/face is visible, "product": true if a physical product/item (packaging, device, food, garment, vehicle, building, etc.) is clearly visible as the main subject, "graphic": true if it is mainly a logo, wordmark, text banner, icon, screenshot or flat brand graphic}' }, { type: 'image_url', image_url: { url: dataUrl(image) } }] }] }), signal: AbortSignal.timeout(60_000) });
|
|
130
130
|
const j = (await res.json());
|
|
131
|
-
|
|
131
|
+
const m = /\{[\s\S]*\}/.exec(j.choices?.[0]?.message?.content || '');
|
|
132
|
+
const d = JSON.parse(m ? m[0] : '{}');
|
|
133
|
+
return { person: !!d.person, product: !!d.product, graphic: !!d.graphic };
|
|
132
134
|
}
|
|
133
135
|
catch {
|
|
134
136
|
return null;
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
|
-
|
|
139
|
+
export async function containsPerson(image) { const c = await classifyRefImage(image); return c ? c.person : null; }
|
|
140
|
+
/** 자동 수집 이미지 중 「사람 없음 · 실물 제품 · 그래픽 아님」만(판정 불가면 제외 — 보수적) */
|
|
138
141
|
export async function filterProductRefs(files, log) {
|
|
139
142
|
const out = [];
|
|
140
143
|
for (const f of files) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
+
const c = await classifyRefImage(f);
|
|
145
|
+
const name = f.split('/').pop();
|
|
146
|
+
if (!c) {
|
|
147
|
+
log?.(` 사이트 이미지 ${name} 판정 불가 → 제외`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (c.person)
|
|
151
|
+
log?.(` 사이트 이미지 ${name} 은 사람이 있어 제외(초상권)`);
|
|
152
|
+
else if (c.graphic || !c.product)
|
|
153
|
+
log?.(` 사이트 이미지 ${name} 은 로고·배너·그래픽이라 제외`);
|
|
144
154
|
else
|
|
145
|
-
|
|
155
|
+
out.push(f);
|
|
146
156
|
}
|
|
147
157
|
return out;
|
|
148
158
|
}
|
|
@@ -17,6 +17,8 @@ type El = {
|
|
|
17
17
|
};
|
|
18
18
|
/** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
|
|
19
19
|
export declare function onColor(hex: string): string;
|
|
20
|
+
/** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
|
|
21
|
+
export declare function wordsEl(text: string, style: Record<string, unknown>, align?: 'left' | 'center'): El;
|
|
20
22
|
/** 한 규격의 트리 */
|
|
21
23
|
export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
|
|
22
24
|
export declare const CTA_LABEL: Record<string, Record<string, string>>;
|
|
@@ -97,6 +97,12 @@ async function fetchLogo(url, dir) {
|
|
|
97
97
|
const h = (type, style, children, extra = {}) => ({ type, props: { style, ...extra, ...(children === undefined ? {} : { children }) } });
|
|
98
98
|
/** 밝은 브랜드색(베이지·노랑·민트) 위엔 어두운 글자 — 상대 명도로 판정 */
|
|
99
99
|
export function onColor(hex) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; return L > 0.62 ? '#0b1526' : '#ffffff'; }
|
|
100
|
+
/** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
|
|
101
|
+
export function wordsEl(text, style, align = 'left') {
|
|
102
|
+
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
103
|
+
const gap = Math.round(Number(style.fontSize || 16) * 0.26);
|
|
104
|
+
return h('div', { display: 'flex', flexWrap: 'wrap', justifyContent: align === 'center' ? 'center' : 'flex-start', columnGap: gap, rowGap: 0, ...style, whiteSpace: 'nowrap' }, words.map((w) => h('span', { whiteSpace: 'nowrap' }, w)));
|
|
105
|
+
}
|
|
100
106
|
function hexA(hex, a) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; }
|
|
101
107
|
/** 한 규격의 트리 */
|
|
102
108
|
export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
@@ -140,15 +146,15 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
140
146
|
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: `0 ${pad}px`, gap: pad }, [brandRow, h('div', { flex: 1, fontSize: headFs, fontWeight: 800, color: fg, letterSpacing: -1, lineHeight: 1.1, whiteSpace: 'nowrap', overflow: 'hidden' }, head), ctaPill]));
|
|
141
147
|
}
|
|
142
148
|
else if (narrow) {
|
|
143
|
-
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [
|
|
149
|
+
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [wordsEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.15, letterSpacing: -0.8 }), wordsEl(body.slice(0, 60), { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4 })]), ctaPill]));
|
|
144
150
|
}
|
|
145
151
|
else {
|
|
146
152
|
const isWide = w / hh > 1.6;
|
|
147
153
|
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [
|
|
148
154
|
h('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, [brandRow, domain && !banner ? h('div', { fontSize: Math.round(headFs * 0.3), fontWeight: 500, color: sub }, domain) : h('div', {}, '')]),
|
|
149
155
|
h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * (banner ? 0.35 : 0.5)), maxWidth: isWide ? Math.round(w * 0.72) : w - pad * 2 }, [
|
|
150
|
-
|
|
151
|
-
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') :
|
|
156
|
+
wordsEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }),
|
|
157
|
+
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : wordsEl(banner ? body.slice(0, 70) : googleLight ? body.slice(0, 48) : body, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }),
|
|
152
158
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
153
159
|
]),
|
|
154
160
|
banner ? h('div', {}, '') : h('div', { display: 'flex', justifyContent: 'space-between', fontSize: Math.round(headFs * 0.28), fontWeight: 500, color: sub }, [h('div', {}, brief.company), h('div', {}, domain)]),
|
|
@@ -209,10 +215,10 @@ export async function renderOverlay(file, w, hh, text, o) {
|
|
|
209
215
|
const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
|
|
210
216
|
const pill = o.kind === 'cta'
|
|
211
217
|
? h('div', { display: 'flex', fontSize: fs1, fontWeight: 700, color: onColor(o.primary), backgroundColor: o.primary, borderRadius: 999, padding: `${Math.round(fs1 * 0.55)}px ${Math.round(fs1 * 1.3)}px`, boxShadow: `0 10px 30px ${hexA(o.primary, 0.45)}` }, text)
|
|
212
|
-
: h('div', { display: 'flex',
|
|
218
|
+
: h('div', { display: 'flex', backgroundColor: 'rgba(11,21,38,.62)', borderRadius: Math.round(fs1 * 0.4), padding: `${Math.round(fs1 * 0.35)}px ${Math.round(fs1 * 0.6)}px`, maxWidth: Math.round(w * 0.86) }, [wordsEl(text, { fontSize: fs1, fontWeight: 800, color: '#fff', lineHeight: 1.25, letterSpacing: -1 }, 'center')]);
|
|
213
219
|
// 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
|
|
214
220
|
const hook = o.kind === 'hook';
|
|
215
|
-
const hookEl = h('div', { display: 'flex',
|
|
221
|
+
const hookEl = h('div', { display: 'flex', maxWidth: Math.round(w * 0.86), backgroundColor: hexA(o.primary, 0.92), padding: `${Math.round(base * 0.035)}px ${Math.round(base * 0.05)}px`, borderRadius: Math.round(base * 0.03), transform: 'rotate(-2deg)' }, [wordsEl(text, { fontSize: Math.round(base * 0.095), fontWeight: 800, color: onColor(o.primary), lineHeight: 1.12, letterSpacing: -1.5, textShadow: '0 4px 24px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.8)' }, 'center')]);
|
|
216
222
|
const el = hook
|
|
217
223
|
? (o.layout === 'lower'
|
|
218
224
|
? h('div', { width: w, height: hh, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: Math.round(hh * 0.22), fontFamily: FONT_FAMILY }, [hookEl])
|
|
@@ -59,6 +59,7 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
59
59
|
- clips[].prompt 는 영어 영상 생성 프롬프트(80~160단어): 피사체·동작·카메라·조명·장르 스타일 토큰·첫 프레임에서 바로 움직임이 시작됨. 사람은 브리프 타겟(${JSON.stringify(brief.audienceProfile || {})})과 어울리는 가상의 인물로, 실존 인물·유명인·타사 로고·화면 글자·자막 금지("no on-screen text, no subtitles, no logos" 로 끝낸다). 소리 지시(음악·효과음) 한 줄 포함.
|
|
60
60
|
- ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 한 문장(영어 ≤ 12단어 · 한국어/일본어 ≤ 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다(뚝 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
|
|
61
61
|
- ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
|
|
62
|
+
- text_hook 포맷의 clips[].prompt 는 「자막 뒤에 깔릴 배경 장면」이다 — 타겟이 일하는/사용하는 실사 장면(사람 뒷모습·손·사무실·제품 사용) 또는 추상 도형. 화면 안에 글자·숫자·차트 라벨·UI 를 그리지 말 것(우리 자막이 글자를 담당 · AI 글자는 깨진다).
|
|
62
63
|
- 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
|
|
63
64
|
- 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
|
|
64
65
|
user: JSON.stringify({ brief: { company: brief.company, offer: brief.offer, category: brief.category, industry: brief.industry, audience: brief.audience, audienceProfile: brief.audienceProfile, usp: brief.usp, proofPoints: brief.proofPoints, tone: brief.tone, language: lang, palette: brief.palette }, concept: { key: concept.key, name: concept.name, angle: concept.angle, headline: concept.headlines[0], body: concept.bodies[0], imagePrompt: concept.imagePrompt }, goal: o.goal, cta, durationSec: o.durationSec, formats: o.formats }),
|
|
@@ -74,3 +74,5 @@ export declare function refCanvas(ref: string, bgPng: string, output: string, di
|
|
|
74
74
|
w: number;
|
|
75
75
|
h: number;
|
|
76
76
|
}): Promise<string>;
|
|
77
|
+
/** 움직임 점수 — 1초·중간·끝 프레임(작게) 사이 평균 픽셀 차. 0~255 · 3 미만이면 사실상 정지 화면(「이미지 하나 붙여놓은 영상」) */
|
|
78
|
+
export declare function motionScore(video: string): Promise<number>;
|
|
@@ -190,3 +190,18 @@ export async function refCanvas(ref, bgPng, output, dims) {
|
|
|
190
190
|
await execFileP('ffmpeg', ['-y', '-v', 'error', '-i', bgPng, '-i', ref, '-filter_complex', `[1:v]scale=${w}:-1:force_original_aspect_ratio=decrease,scale='min(iw,${w})':'min(ih,${Math.round(dims.h * 0.7)})':force_original_aspect_ratio=decrease[p];[0:v]scale=${dims.w}:${dims.h}[b];[b][p]overlay=(W-w)/2:(H-h)/2,format=rgb24`, '-frames:v', '1', output]);
|
|
191
191
|
return output;
|
|
192
192
|
}
|
|
193
|
+
/** 움직임 점수 — 1초·중간·끝 프레임(작게) 사이 평균 픽셀 차. 0~255 · 3 미만이면 사실상 정지 화면(「이미지 하나 붙여놓은 영상」) */
|
|
194
|
+
export async function motionScore(video) {
|
|
195
|
+
try {
|
|
196
|
+
const p = await probe(video);
|
|
197
|
+
const W = 48, H = 84;
|
|
198
|
+
const grab = async (t) => { const { stdout } = await execFileP('ffmpeg', ['-v', 'error', '-ss', t.toFixed(2), '-i', video, '-frames:v', '1', '-vf', `scale=${W}:${H}`, '-pix_fmt', 'gray', '-f', 'rawvideo', '-'], { encoding: 'buffer', maxBuffer: 1 << 20 }); return stdout; };
|
|
199
|
+
const a = await grab(Math.min(0.8, p.durationSec * 0.1)), b = await grab(p.durationSec * 0.5), c = await grab(Math.max(0.5, p.durationSec - 0.6));
|
|
200
|
+
const diff = (x, y) => { let d = 0; const n = Math.min(x.length, y.length); for (let i = 0; i < n; i++)
|
|
201
|
+
d += Math.abs(x[i] - y[i]); return n ? d / n : 0; };
|
|
202
|
+
return Math.max(diff(a, b), diff(b, c));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return 99;
|
|
206
|
+
}
|
|
207
|
+
}
|
package/package.json
CHANGED