adyou 0.6.12 → 0.6.14

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,7 +1,7 @@
1
1
  // 컨셉·카피 — 브리프 → 컨셉 3~5(혜택·증거·긴급·비교·감성) × 제목 5·본문 5·설명 2·CTA. 규격 검사(specs.checkCopy)를 통과할 때까지 다듬는다.
2
2
  import { z } from 'zod';
3
3
  import { aiAvailable, completeJson } from '../llm.js';
4
- import { checkCopy, ctaFor, fit, GOOGLE_LIMITS, wlen } from './specs.js';
4
+ import { checkCopy, ctaFor, fit, fitWords, GOOGLE_LIMITS, wlen } from './specs.js';
5
5
  const ConceptSchema = z.object({
6
6
  key: z.string().regex(/^[a-z][a-z0-9_]{1,19}$/), name: z.string().optional(), angle: z.string().optional(),
7
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),
@@ -36,14 +36,14 @@ export async function generateConcepts(brief, opts) {
36
36
  const fixed = await generateConcepts(brief, { ...opts, feedback: `LANGUAGE_FIX: 직전 답에 한국어가 섞였어요. headlines·bodies·descriptions·videoLines·videoVoice·name·angle 전부 ${brief.language} 로만 다시 써 주세요(브리프·요구사항이 한국어여도 출력은 ${brief.language}).` });
37
37
  return fixed;
38
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 }));
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) => fitWords(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 }));
40
40
  return concepts;
41
41
  }
42
42
  function fallbackConcepts(brief, opts) {
43
43
  const cta = ctaFor(opts.goal);
44
44
  const uc = opts.userCopy;
45
45
  if (uc && (uc.headlines?.length || uc.bodies?.length)) {
46
- const heads = (uc.headlines || []).map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean);
46
+ const heads = (uc.headlines || []).map((h) => fitWords(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean);
47
47
  const bodies = (uc.bodies || []).map((b) => b.trim()).filter(Boolean);
48
48
  const descs = (uc.descriptions || []).map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean);
49
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) }];
@@ -14,6 +14,8 @@ export declare const BG_RATIOS: Record<string, {
14
14
  /** 🔴 구글은 한글·전각을 2자로 센다 */
15
15
  export declare function wlen(t: string): number;
16
16
  export declare function fit(t: string, limit: number): string;
17
+ /** 어절 경계에서 한도 안으로 — 헤드라인이 「로드맵이 알」처럼 낱말 중간에서 잘리지 않게(인프런 엔드카드 · 2026-09-17). 띄어쓰기 없으면 fit 으로 */
18
+ export declare function fitWords(t: string, limit: number): string;
17
19
  export declare const GOOGLE_LIMITS: {
18
20
  headline: number;
19
21
  longHeadline: number;
@@ -18,6 +18,21 @@ export const BG_RATIOS = { '1x1': { openai: '1024x1024', aspect: '1:1' }, '4x5':
18
18
  export function wlen(t) { return [...t].reduce((n, ch) => n + ((ch.codePointAt(0) || 0) > 0x2e7f ? 2 : 1), 0); }
19
19
  export function fit(t, limit) { let s = t; while (wlen(s) > limit && s.length)
20
20
  s = s.slice(0, -1); return s.replace(/[\s·,\-—:;]+$/, ''); }
21
+ /** 어절 경계에서 한도 안으로 — 헤드라인이 「로드맵이 알」처럼 낱말 중간에서 잘리지 않게(인프런 엔드카드 · 2026-09-17). 띄어쓰기 없으면 fit 으로 */
22
+ export function fitWords(t, limit) {
23
+ const s = t.trim().replace(/\s+/g, ' ');
24
+ if (wlen(s) <= limit)
25
+ return s;
26
+ if (!s.includes(' '))
27
+ return fit(s, limit);
28
+ let out = '';
29
+ for (const w of s.split(' ')) {
30
+ if (wlen((out + ' ' + w).trim()) > limit)
31
+ break;
32
+ out = (out + ' ' + w).trim();
33
+ }
34
+ return (wlen(out) >= limit * 0.4 ? out : fit(s, limit)).replace(/[\s·,\-—:;]+$/, '');
35
+ }
21
36
  export const GOOGLE_LIMITS = { headline: 30, longHeadline: 90, description: 90, businessName: 25 };
22
37
  /** RDA call_to_action_text는 구글이 정한 영문 문구만 */
23
38
  export const GOOGLE_CTA = ['Apply Now', 'Book Now', 'Contact Us', 'Download', 'Learn More', 'Install', 'Visit Site', 'Shop Now', 'Sign Up', 'Get Quote', 'Subscribe', 'See More'];
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { aiAvailable, completeJson } from '../llm.js';
5
5
  import { ctaLabel } from './render.js';
6
6
  import { FORMAT_DESC, genreStyle, PACE, playbookFor, sceneCuts, TONE_WORDS } from './playbook.js';
7
- import { fit, GOOGLE_LIMITS, policyIssues, wlen } from './specs.js';
7
+ import { fit, fitWords, GOOGLE_LIMITS, policyIssues, wlen } from './specs.js';
8
8
  const BoardSchema = z.object({
9
9
  format: z.string(), genre: z.string().optional(), hook: z.string(), captions: z.array(z.string()).min(1).max(5), voice: z.string().optional().nullable(), music: z.string().optional(),
10
10
  // 🔴 kind 는 우리 클립 계획(clipPlan)이 정하므로 모델이 「ugc」「speech」 같은 값을 써도 거절하지 않는다(에어서울 콘티 2회 실패 · 2026-09-17) · sec 도 계획값을 쓴다
@@ -16,10 +16,19 @@ export const langName = (l) => LANG_NAME[l] || LANG_NAME[l.split('-')[0]] || l;
16
16
  /** 훅 길이 제한 — 한글·전각 12자(wlen 24) · 라틴은 단어 경계에서 자른다(잘린 단어 금지) */
17
17
  export function fitHook(t) {
18
18
  const s = t.trim().replace(/\s+/g, ' ');
19
- if (wlen(s) <= 26)
19
+ if (wlen(s) <= 30)
20
20
  return s;
21
- if (/[\u3000-\u9fff\uac00-\ud7af]/.test(s))
22
- return fit(s, 26);
21
+ // 🔴 한글도 어절 경계에서 — 글자 단위로 자르면 「현대건설이 짓는, 힐스테이」(힐스테이트 실사고 · 2026-09-17). 한도 15자(wlen 30) · 줄나눔은 렌더가 맡는다
22
+ if (/[\u3000-\u9fff\uac00-\ud7af]/.test(s)) {
23
+ const ws = s.split(' ');
24
+ let out = '';
25
+ for (const w of ws) {
26
+ if (wlen((out + ' ' + w).trim()) > 30)
27
+ break;
28
+ out = (out + ' ' + w).trim();
29
+ }
30
+ return (out || fit(s, 30)).replace(/[\s,·:;\-—]+$/, '');
31
+ }
23
32
  const words = s.split(' ');
24
33
  let out = '';
25
34
  for (const w of words) {
@@ -88,7 +97,7 @@ export async function generateStoryboards(brief, concept, o) {
88
97
  const clips = p.clips.map((pc, i) => { const src = b.clips[i] || b.clips[b.clips.length - 1]; return { sec: pc.sec, kind: pc.kind, prompt: (src?.prompt || '').trim(), speech: src?.speech || undefined, continues: i > 0 ? !!src?.continues : false }; });
89
98
  const caps = b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3);
90
99
  // 🔴 장면 포맷의 voice 를 모델이 비우면 내레이션 없는 「음악만」 영상이 된다(힐스테이트 글자 훅 · 2026-09-17) → 자막 두 줄로 내레이션을 만든다
91
- const voice = b.voice?.trim() || (p.format !== 'ugc_selfie' ? [caps[0], caps[1]].filter(Boolean).join(', ').replace(/[.!?。!?]?$/, '.') : undefined);
100
+ const voice = b.voice?.trim() || (p.format !== 'ugc_selfie' ? [caps[0], caps[1]].filter(Boolean).map((c, i, a) => i < a.length - 1 && !/[.!?。!?,]$/.test(c) ? c + ',' : c).join(' ').replace(/[,]?$/, '').replace(/[^.!?。!?]$/, (m) => m + '.') : undefined);
92
101
  boards.push({ id: `${concept.key}_${p.format}`, format: p.format, genre: p.format === 'ugc_selfie' ? 'ugc' : (b.genre && genres.includes(b.genre) ? b.genre : genres[0]) || 'photoreal', hook: fitHook(b.hook), captions: caps, voice, music: b.music || tone.music, cta, durationSec: o.durationSec, clips, lang, cast: b.cast || undefined });
93
102
  }
94
103
  return boards;
@@ -161,7 +170,7 @@ export async function translateCopy(brief, concept, boards, langs) {
161
170
  t = { headlines: concept.headlines, bodies: concept.bodies, descriptions: concept.descriptions, boards: [] };
162
171
  }
163
172
  }
164
- out[l] = { headlines: t.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean), bodies: t.bodies.map((b) => b.trim()).filter(Boolean), descriptions: t.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean) };
173
+ out[l] = { headlines: t.headlines.map((h) => fitWords(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean), bodies: t.bodies.map((b) => b.trim()).filter(Boolean), descriptions: t.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean) };
165
174
  for (const b of boards) {
166
175
  const tb = t.boards?.find((x) => x.id === b.id);
167
176
  if (tb)
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export * from './core/money.js';
3
3
  export * from './core/llm.js';
4
4
  export * from './core/site.js';
5
5
  export * from './core/brief.js';
6
- export { SIZES, BG_RATIOS, wlen, fit, GOOGLE_LIMITS, GOOGLE_CTA, META_CTA, ctaFor, policyIssues, checkCopy, type Size, type CopyCheck } from './core/creatives/specs.js';
6
+ export { SIZES, BG_RATIOS, wlen, fit, fitWords, GOOGLE_LIMITS, GOOGLE_CTA, META_CTA, ctaFor, policyIssues, checkCopy, type Size, type CopyCheck } from './core/creatives/specs.js';
7
7
  export * from './core/creatives/copy.js';
8
8
  export * from './core/creatives/images.js';
9
9
  export * from './core/creatives/render.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export * from './core/money.js';
4
4
  export * from './core/llm.js';
5
5
  export * from './core/site.js';
6
6
  export * from './core/brief.js';
7
- export { SIZES, BG_RATIOS, wlen, fit, GOOGLE_LIMITS, GOOGLE_CTA, META_CTA, ctaFor, policyIssues, checkCopy } from './core/creatives/specs.js';
7
+ export { SIZES, BG_RATIOS, wlen, fit, fitWords, GOOGLE_LIMITS, GOOGLE_CTA, META_CTA, ctaFor, policyIssues, checkCopy } from './core/creatives/specs.js';
8
8
  export * from './core/creatives/copy.js';
9
9
  export * from './core/creatives/images.js';
10
10
  export * from './core/creatives/render.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",