adyou 0.6.0 → 0.6.2

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.
@@ -59,7 +59,9 @@ export async function buildBrief(url, opts) {
59
59
  - proofPoints: 숫자·수상·후기·보증 등 사이트에 실제로 있는 증거 문구 0~5(없으면 빈 배열 · 만들지 않는다)`,
60
60
  user: JSON.stringify({ url: site.finalUrl, title: site.title, description: site.description, lang: site.lang, headings: site.headings, prices: site.prices, colorsFound: site.colors.slice(0, 8), themeColor: site.themeColor, textExcerpt: site.text.slice(0, 3500), goal: opts.goal, markets: opts.markets, hints: opts.hints || '' }),
61
61
  });
62
- brief = { url: site.finalUrl, ...out, palette: { primary: /^#[0-9a-f]{6}$/i.test(out.palette.primary) ? out.palette.primary.toLowerCase() : fallbackPalette.primary, dark: /^#[0-9a-f]{6}$/i.test(out.palette.dark) ? out.palette.dark : fallbackPalette.dark, light: /^#[0-9a-f]{6}$/i.test(out.palette.light) ? out.palette.light : fallbackPalette.light }, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: out.specialCategory || special, siteImages: site.images };
62
+ // 🔴 LLM 업종 (realestate_construction ) specialCategory 넣는 오탐(2026-09-16) 실제 특별 카테고리 낱말이 있을 때만 인정
63
+ const specialOk = (v) => v && /금융|대출|투자|보험|암호|도박|카지노|정치|선거|의료|의약|병원|시술|담배|성인|finance|gambl|politic|medical|pharma|tobacco|adult/i.test(v) ? v : null;
64
+ brief = { url: site.finalUrl, ...out, specialCategory: specialOk(out.specialCategory) || special, palette: { primary: /^#[0-9a-f]{6}$/i.test(out.palette.primary) ? out.palette.primary.toLowerCase() : fallbackPalette.primary, dark: /^#[0-9a-f]{6}$/i.test(out.palette.dark) ? out.palette.dark : fallbackPalette.dark, light: /^#[0-9a-f]{6}$/i.test(out.palette.light) ? out.palette.light : fallbackPalette.light }, logoUrl: site.logo, ogImage: site.ogImage, siteImages: site.images };
63
65
  }
64
66
  else {
65
67
  brief = { url: site.finalUrl, company: site.title.split(/[|·—\-–:]/)[0].trim() || new URL(site.finalUrl).hostname, offer: site.description || site.headings[0] || site.title, category: '', audience: ['사이트 방문자'], usp: site.headings.slice(0, 3), tone: '단정하고 명확', language: site.lang || 'ko', palette: fallbackPalette, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: special, industry: 'other', visualGenres: ['photoreal'], siteImages: site.images, notes: 'LLM 키가 없어 규칙으로 만든 최소 브리프예요. `adpilot brief --edit` 로 다듬어 주세요.' };
@@ -4,7 +4,7 @@
4
4
  // CLI(ops.ts)와 웹(pipeline.ts)이 같은 함수를 부른다. 원가는 onAsset 으로 건별 통보(청구는 호출자 · ×1.1).
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { DERIVED_RATIOS, VIDEO_DIMS } from './media.js';
7
+ import { DERIVED_RATIOS, VIDEO_DIMS, hasSeedance, speechVideoModel } from './media.js';
8
8
  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';
@@ -14,7 +14,7 @@ import { synthesizeSpeech, ttsAvailable } from './tts.js';
14
14
  import { isMockGen } from './images.js';
15
15
  import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
16
16
  /** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
17
- export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
17
+ export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || hasSeedance() || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
18
18
  /** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
19
19
  export async function renderEndCard(dir, brief, concept, ratio, lang, backgrounds = {}) {
20
20
  try {
@@ -113,7 +113,7 @@ export async function produceConceptVideos(o) {
113
113
  const line = clip.speech ? (rl === board.lang ? clip.speech : lb0.speech || lb0.voice || clip.speech) : undefined;
114
114
  const mode = line ? speechModeFor(rl) : 'none';
115
115
  // 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
116
- 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 talks to the camera with expressive face and natural hand gestures, as if sharing a genuine reaction (a voice-over will be added — do NOT render intelligible dialogue; keep mouth movement subtle). Audio: light background music only, no speech, no dialogue.` : '';
116
+ 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
117
  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
118
  // 첫 프레임: 참고(제품) 사진이 있으면 그 사진을 캔버스에 앉힌 프레임(실제 제품이 움직임) · 없으면 생성 배경 · 사람 포맷은 없음
119
119
  let firstFrame = !person && ci === 0 ? (o.backgrounds[job.ratio] || undefined) : undefined;
@@ -126,16 +126,20 @@ export async function produceConceptVideos(o) {
126
126
  }
127
127
  catch { /* 생성 배경 유지 */ }
128
128
  }
129
+ // 말하는 클립의 모델 — 영어 아닌 언어는 Seedance 네이티브 발화(립싱크) · 영어는 Veo
130
+ const clipModel = line && mode === 'native' ? speechVideoModel(rl, job.model) : job.model;
131
+ if (clipModel !== job.model)
132
+ log(` ${langName(rl)} 대사는 Seedance 2.5 네이티브 발화로(립싱크)`);
129
133
  let r;
130
134
  try {
131
- 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 });
135
+ r = await generateVideo({ file: cf, prompt, ratio: job.ratio, durationSec: clip.sec, model: clipModel, 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 });
132
136
  }
133
137
  catch (e) {
134
138
  if (!e.retryable)
135
139
  throw e;
136
140
  costKrw += Number(e.costKrw || 0);
137
141
  log(` 안전 필터로 결과가 비어 와서 다른 테이크로 한 번 더 만들어요`);
138
- r = await generateVideo({ file: cf, prompt: `${prompt} Alternative take: different framing and wardrobe, softer lighting, keep everything family-friendly and brand-safe.`, ratio: job.ratio, durationSec: clip.sec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
142
+ r = await generateVideo({ file: cf, prompt: `${prompt} Alternative take: different framing and wardrobe, softer lighting, keep everything family-friendly and brand-safe.`, ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
139
143
  }
140
144
  costKrw += r.costKrw;
141
145
  // 대사 꼬리 검사 — 마지막 0.7초가 아직 시끄러우면(≥ -27dB) 말이 끝까지 이어진 것 → 더 짧은 대사로 1회 재생성(원가 1회 추가)
@@ -145,7 +149,7 @@ export async function produceConceptVideos(o) {
145
149
  log(` 대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB) → 더 짧게 한 번 더 만들어요`);
146
150
  const short = line.split(/(?<=[.!?。!?])\s+/)[0] || line;
147
151
  try {
148
- const r2 = await generateVideo({ file: cf, prompt: prompt.replace(`"${line}"`, `"${short}"`).replace('finishes comfortably by second', 'finishes clearly by second'), ratio: job.ratio, durationSec: clip.sec, model: job.model, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
152
+ const r2 = await generateVideo({ file: cf, prompt: prompt.replace(`"${line}"`, `"${short}"`).replace('finishes comfortably by second', 'finishes clearly by second'), ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, firstFrame, refs: undefined, negative: 'text, subtitles, letters, watermark, logo', log });
149
153
  costKrw += r2.costKrw;
150
154
  const t2 = await tailLoudnessDb(cf, 0.7);
151
155
  log(` 다시 만든 컷 꼬리 ${t2.toFixed(0)}dB${t2 >= -27 ? ' (여전히 이어짐 · 그대로 사용)' : ' ✓'}`);
@@ -167,7 +171,7 @@ export async function produceConceptVideos(o) {
167
171
  await mixVoiceover(cf, v.file, mixed, { duck: 0.08 });
168
172
  fs.copyFileSync(mixed, cf);
169
173
  costKrw += v.costKrw;
170
- log(' → TTS 내레이션으로 대체 ✓');
174
+ log(' → TTS 내레이션으로 대체 ✓ (입모양은 안 맞을 수 있어요 · 자막 중심)');
171
175
  }
172
176
  catch (e) {
173
177
  log(` ⚠ TTS 대체 실패: ${e instanceof Error ? e.message.slice(0, 100) : e}`);
@@ -103,12 +103,16 @@ export declare const SOURCE_LABEL: Record<CreativeSource, string>;
103
103
  * - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
104
104
  * - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
105
105
  */
106
+ /** 말하는 포맷(셀카 후기)의 음성 모델 — 영어는 Veo 네이티브 · 그 외는 Seedance 2.5 네이티브(한국어·일본어 립싱크 검증 · sosigi) · ARK 키 없으면 Veo(+TTS 내레이션 · 인물은 말 안 함) */
107
+ export declare const hasSeedance: () => boolean;
108
+ export declare function speechVideoModel(lang: string, fallback: string): string;
106
109
  export declare function resolveCreativePlan(spec: CreativeSpec | undefined, ctx: {
107
110
  monthlyKrw: number;
108
111
  media: Medium[];
109
112
  industry?: string | null;
110
113
  genres?: string[];
111
- hasScreens?: boolean;
114
+ hasScreens?: boolean; /** 시장 언어(말하는 포맷 모델·원가 배정) */
115
+ langs?: string[];
112
116
  }): CreativePlan;
113
117
  /** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
114
118
  export declare const DERIVED_RATIOS: Record<VideoRatio, VideoRatio[]>;
@@ -37,6 +37,10 @@ export const SOURCE_LABEL = { ai: 'AI가 전부 만들기', guided: '내 요구
37
37
  * - 소리(모델 네이티브 BGM·효과음·대사)는 기본 켬 · 자막(헤드라인 번인)·엔드카드(로고·CTA 2초)는 기본 켬
38
38
  * - 예산이 작으면(월 50만 미만) 영상은 대표 컨셉 1개에만 · 월 200만 이상이면 표준 화질(Veo fast)
39
39
  */
40
+ /** 말하는 포맷(셀카 후기)의 음성 모델 — 영어는 Veo 네이티브 · 그 외는 Seedance 2.5 네이티브(한국어·일본어 립싱크 검증 · sosigi) · ARK 키 없으면 Veo(+TTS 내레이션 · 인물은 말 안 함) */
41
+ export const hasSeedance = () => !!process.env.ARK_API_KEY;
42
+ export function speechVideoModel(lang, fallback) { return lang.split('-')[0] !== 'en' && hasSeedance() ? 'dreamina-seedance-2-5-260628' : fallback; }
43
+ const SPEAKING_FORMATS = ['ugc_selfie'];
40
44
  export function resolveCreativePlan(spec, ctx) {
41
45
  const s = spec || {};
42
46
  // 기본 = 이미지 + 문구(안전) · 영상은 베타(광고주 선택) — 2026-09-16 사장님 결정
@@ -67,7 +71,11 @@ export function resolveCreativePlan(spec, ctx) {
67
71
  const sec = Math.min(duration, m.maxSec);
68
72
  // ui_demo 는 8초 중 4초만 생성(나머지는 실제 화면 · 무료)
69
73
  const genSec = formats[i] === 'ui_demo' && sec <= 8 && (ctx.hasScreens ?? true) ? Math.round(sec / 2) : sec;
70
- 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] });
74
+ // 말하는 포맷: 영어 아닌 시장 언어가 있으면 언어 클립은 Seedance 원가로(언어별 클립 · 언어 수만큼)
75
+ const langs = ctx.langs?.length ? ctx.langs : ['ko'];
76
+ const speaking = SPEAKING_FORMATS.includes(formats[i]);
77
+ const perLang = speaking ? langs.map((l) => { const mm = VIDEO_MODELS[speechVideoModel(l, model)]; return Math.round(mm.perSecKrw[resolution] * Math.min(sec, mm.maxSec)); }) : [Math.round(m.perSecKrw[resolution] * genSec)];
78
+ 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: perLang.reduce((a, b) => a + b, 0), format: formats[i] });
71
79
  }
72
80
  const conceptsWithVideo = count ? (small && mode !== 'video' ? 1 : concepts) : 0;
73
81
  const img = IMAGE_MODELS[imgTier];
@@ -123,9 +123,10 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
123
123
  // 글자 크기 — 메타 포스터(피드·스토리)는 폰 화면에서 읽히게 크게(짧은 변의 11~12% · 메타는 텍스트 20% 규칙이 없음 · 2021 폐지)
124
124
  // 구글 디스플레이 이미지는 반대로 글자를 적게(구글이 헤드라인·설명을 따로 얹고, 이미지 텍스트 20% 초과는 비승인 위험) → 헤드라인만 작게
125
125
  const googleLight = size.medium === 'google' && !banner;
126
- const headFs = short ? Math.round(hh * 0.34) : narrow ? Math.round(w * 0.14) : banner ? Math.round(base * 0.12) : googleLight ? Math.round(base * 0.062) : Math.round(base * (hh > w ? 0.12 : 0.105));
127
- const bodyFs = short ? 0 : narrow ? Math.round(w * 0.085) : banner ? Math.round(base * 0.062) : googleLight ? 0 : Math.round(base * 0.046);
128
- const ctaFs = short ? Math.round(hh * 0.28) : narrow ? Math.round(w * 0.09) : banner ? Math.round(base * 0.06) : googleLight ? Math.round(base * 0.034) : Math.round(base * 0.04);
126
+ // 구글 이미지(1200×628·1200×1200) 텍스트 20% 안에서 헤드라인 8.5% + 본문 3.6% 너무 비면 썰렁(사장님 검수) · 메타보다 단계 작게
127
+ const headFs = short ? Math.round(hh * 0.34) : narrow ? Math.round(w * 0.14) : banner ? Math.round(base * 0.12) : googleLight ? Math.round(base * 0.085) : Math.round(base * (hh > w ? 0.12 : 0.105));
128
+ const bodyFs = short ? 0 : narrow ? Math.round(w * 0.085) : banner ? Math.round(base * 0.062) : googleLight ? Math.round(base * 0.036) : Math.round(base * 0.046);
129
+ const ctaFs = short ? Math.round(hh * 0.28) : narrow ? Math.round(w * 0.09) : banner ? Math.round(base * 0.06) : googleLight ? Math.round(base * 0.036) : Math.round(base * 0.04);
129
130
  const children = [];
130
131
  if (bg)
131
132
  children.push(h('img', { position: 'absolute', top: 0, left: 0, width: w, height: hh, objectFit: 'cover', filter: dark ? 'brightness(.66)' : 'brightness(1.02)' }, undefined, { src: bg, width: w, height: hh }));
@@ -147,7 +148,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
147
148
  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', {}, '')]),
148
149
  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 }, [
149
150
  h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5, wordBreak: 'keep-all' }, head),
150
- (banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45, wordBreak: 'keep-all' }, banner ? body.slice(0, 70) : body),
151
+ (banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45, wordBreak: 'keep-all' }, banner ? body.slice(0, 70) : googleLight ? body.slice(0, 48) : body),
151
152
  h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
152
153
  ]),
153
154
  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)]),
@@ -70,7 +70,7 @@ async function veo(i, cfg) {
70
70
  costKrw = s.cost_krw;
71
71
  // 🔴 Veo 「No video in response」 = 안전 필터(실사 인물·식품 등)로 결과가 비어 옴 · 접수비는 발생 → 호출자가 다른 테이크로 1회 재시도(retryable)
72
72
  if (s.status === 'failed')
73
- throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw, retryable: /No video in response|filtered|safety/i.test(String(s.error || '')) });
73
+ throw Object.assign(new Error(`영상 생성 실패: ${s.error || '알 수 없음'}`), { fatal: true, costKrw, retryable: /No video in response|filtered|safety|internal server|try again|temporar/i.test(String(s.error || '')) });
74
74
  if (s.status === 'completed') {
75
75
  const dl = await fetch(`${cfg.base}/v1/video/generation/${acc.operation_id}/download`, { headers: H, signal: AbortSignal.timeout(300_000) });
76
76
  if (!dl.ok)
package/dist/core/ops.js CHANGED
@@ -62,7 +62,7 @@ export async function opCreatives(p, o) {
62
62
  spec.video = { ...(spec.video || {}), count: 1 };
63
63
  if (o.videoSeconds)
64
64
  spec.video = { ...(spec.video || {}), durationSec: (o.videoSeconds <= 8 ? 8 : o.videoSeconds <= 15 ? 15 : 30) };
65
- const cplan = resolveCreativePlan(spec, { monthlyKrw: p.budget.monthlyKrw, media: connectedMedia(), industry: p.brief.industry, genres: p.brief.visualGenres, hasScreens: true });
65
+ const cplan = resolveCreativePlan(spec, { monthlyKrw: p.budget.monthlyKrw, media: connectedMedia(), industry: p.brief.industry, genres: p.brief.visualGenres, hasScreens: true, langs: [...new Set(p.budget.markets.map((m) => m.language))] });
66
66
  p.creative = spec;
67
67
  const count = o.count || cplan.concepts;
68
68
  const dir = creativesDir(p.slug);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",