adyou 0.6.15 → 0.6.17
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 +16 -4
- package/dist/core/creatives/linebreak.d.ts +28 -0
- package/dist/core/creatives/linebreak.js +285 -0
- package/dist/core/creatives/media.d.ts +4 -1
- package/dist/core/creatives/media.js +6 -2
- package/dist/core/creatives/render.d.ts +5 -7
- package/dist/core/creatives/render.js +28 -60
- package/package.json +3 -2
|
@@ -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, hasSeedance, speechVideoModel } from './media.js';
|
|
7
|
+
import { DERIVED_RATIOS, RETAKE_CAP_RATIO, VIDEO_DIMS, hasSeedance, speechVideoModel } from './media.js';
|
|
8
8
|
import { genreStyle, PACE, SCENE_FORMATS, 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';
|
|
@@ -119,6 +119,18 @@ export async function produceConceptVideos(o) {
|
|
|
119
119
|
log(`「${c.name}」 ${format} 영상 ${job.ratio.replace('x', ':')} ${job.durationSec}초${speaks ? ` · ${langName(rl)} 대사` : ''} — 훅 「${lb0.hook}」 (${job.provider === 'veo' ? 'Veo 3.1' : 'Seedance 2.5'} · 장르 ${board.genre})`);
|
|
120
120
|
const clipFiles = [];
|
|
121
121
|
let failed = false;
|
|
122
|
+
// 재생성 예산 — 이 영상(포맷×언어)의 누적 원가가 예상 단가 × RETAKE_CAP_RATIO 를 넘기면 더 다시 만들지 않는다. Seedance(₩2,669/컷)는 컷당 1회만. 광고주 크레딧에서 차감되므로(무료 아님) 상한을 둔다 · 2026-09-17 사장님
|
|
123
|
+
const retakeCap = Math.max(1, (job.unitKrw || 0)) * RETAKE_CAP_RATIO;
|
|
124
|
+
const retakesByCut = {};
|
|
125
|
+
const canRetake = (ci, nextKrw, expensive, why) => {
|
|
126
|
+
const used = retakesByCut[ci] || 0;
|
|
127
|
+
if (used >= (expensive ? 1 : 2) || costKrw + nextKrw > retakeCap) {
|
|
128
|
+
log(` ${why} — 재생성 상한(영상마다 예상 원가의 ${RETAKE_CAP_RATIO}배 · 지금까지 ₩${Math.round(costKrw).toLocaleString()})에 닿아 이 테이크를 그대로 써요`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
retakesByCut[ci] = used + 1;
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
122
134
|
for (const [ci, clip] of board.clips.entries()) {
|
|
123
135
|
const cf = path.join(o.dir, `${key}_${rl}_${job.ratio}_c${ci}.mp4`);
|
|
124
136
|
try {
|
|
@@ -190,7 +202,7 @@ export async function produceConceptVideos(o) {
|
|
|
190
202
|
// 대사 꼬리 검사 — 마지막 0.7초가 아직 시끄러우면(≥ -27dB) 말이 끝까지 이어진 것 → 더 짧은 대사로 1회 재생성(원가 1회 추가)
|
|
191
203
|
if (line && ff) {
|
|
192
204
|
const tail = await tailLoudnessDb(cf, 0.7);
|
|
193
|
-
if (tail >= -27) {
|
|
205
|
+
if (tail >= -27 && canRetake(ci, r.costKrw, clipModel !== job.model, `대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB)`)) {
|
|
194
206
|
log(` 대사가 끝까지 이어져요(꼬리 ${tail.toFixed(0)}dB) → 더 짧게 한 번 더 만들어요`);
|
|
195
207
|
const short = line.split(/(?<=[.!?。!?])\s+/)[0] || line;
|
|
196
208
|
try {
|
|
@@ -227,7 +239,7 @@ export async function produceConceptVideos(o) {
|
|
|
227
239
|
if (ff && !isMockGen()) {
|
|
228
240
|
const art = await checkArtifacts(cf);
|
|
229
241
|
log(` ${art.note}`);
|
|
230
|
-
if (!art.ok) {
|
|
242
|
+
if (!art.ok && canRetake(ci, r.costKrw, clipModel !== job.model, '비주얼 결함')) {
|
|
231
243
|
try {
|
|
232
244
|
const r4 = await generateVideo({ file: cf, prompt: `${prompt} Physically plausible and clean: exactly one of each object, correct anatomy (five fingers), no duplicated or overlapping items, no melting or warped shapes.`, ratio: job.ratio, durationSec: clip.sec, model: clipModel, resolution: job.resolution, audio: job.audio, firstFrame, negative: 'duplicated objects, double image, extra fingers, extra limbs, deformed hands, warped face, melting, floating parts, text, subtitles, watermark, logo', log });
|
|
233
245
|
costKrw += r4.costKrw;
|
|
@@ -242,7 +254,7 @@ export async function produceConceptVideos(o) {
|
|
|
242
254
|
// QA ②-0 움직임: 사실상 정지 화면이면 첫 프레임 없이 「역동적 카메라」로 1회 재생성(원가 추가)
|
|
243
255
|
if (ff && !isMockGen()) {
|
|
244
256
|
const mo = await motionScore(cf);
|
|
245
|
-
if (mo < 3) {
|
|
257
|
+
if (mo < 3 && canRetake(ci, r.costKrw, clipModel !== job.model, `움직임이 거의 없어요(점수 ${mo.toFixed(1)})`)) {
|
|
246
258
|
log(` 움직임이 거의 없어요(점수 ${mo.toFixed(1)}) → 장면을 다시 만들어요`);
|
|
247
259
|
try {
|
|
248
260
|
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 });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type FontRec = {
|
|
2
|
+
name: string;
|
|
3
|
+
data: ArrayBuffer;
|
|
4
|
+
weight: number;
|
|
5
|
+
};
|
|
6
|
+
/** 글꼴 등록 — render.loadFonts 가 satori 에 넘기는 목록을 그대로 넣는다(파싱은 첫 측정 때 굵기별로 게으르게) */
|
|
7
|
+
export declare function registerFonts(list: FontRec[]): void;
|
|
8
|
+
/** 어림 폭(글꼴이 없을 때만 · Pretendard 실측): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
|
|
9
|
+
export declare const estimateW: (t: string, fs: number) => number;
|
|
10
|
+
/** 실제 글꼴 폭 — 굵기가 같은 글꼴을 등록 순서(Pretendard → JP → CJK → Thai)로 훑어 글리프가 있는 첫 글꼴의 advance 를 더한다(satori 폴백과 같은 순서). letterSpacing 은 satori 처럼 (글자 수−1)배 */
|
|
11
|
+
export declare function measureText(t: string, fs: number, o?: {
|
|
12
|
+
weight?: number;
|
|
13
|
+
letterSpacing?: number;
|
|
14
|
+
}): number;
|
|
15
|
+
export declare function hasMeasureFonts(): boolean;
|
|
16
|
+
/** 어절 토큰화 + 한 글자 한글 어절 결속 — 「한 번」「안 팔아요」「수 있어요」「이 동네」는 한 덩어리 */
|
|
17
|
+
export declare function tokenize(text: string): string[];
|
|
18
|
+
/** 어절 i 뒤에서 끊는 비용(음수=좋은 자리) — i 는 앞 줄의 마지막 어절, next 는 다음 줄의 첫 어절 */
|
|
19
|
+
export declare function breakCost(prev: string, next: string): number;
|
|
20
|
+
export type BreakOpts = {
|
|
21
|
+
maxLines?: number;
|
|
22
|
+
weight?: number;
|
|
23
|
+
letterSpacing?: number; /** 폭 안전 여백(기본 2% · 글꼴 셰이핑 오차) */
|
|
24
|
+
margin?: number;
|
|
25
|
+
};
|
|
26
|
+
/** 줄나눔 — 한 줄에 들어가면 그대로 · 아니면 줄 수를 2부터 늘려가며 모든 어절 경계 조합 중 비용 최소(결속 규칙 + 줄 균형 + 고아 벌점)를 고른다. 어떤 조합도 폭 안에 못 들어가면(한 어절이 줄보다 김) 뒤에서부터 채운 줄을 돌려주고 linesEl 이 글자를 줄인다. */
|
|
27
|
+
export declare function breakLines(text: string, fs: number, maxW: number, o?: BreakOpts | number): string[];
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// 헤드라인·훅·자막 줄나눔 — 「오늘의 입술, / 틴트 한 번」·「나에게 맞는 / 컬러 찾기」처럼 읽기 자연스러운 어절 경계에서만 줄을 바꾼다(2026-09-18 사장님 · 티르티르 영상 엔드카드 「틴트 / 한 번」·「컬러 / 찾기」).
|
|
2
|
+
// 왜 규칙인가: LLM 은 비용·지연·재현성이 나쁘고, 한국어 헤드라인(≤30자·어절 ≤12개)은 전 조합을 다 재봐도 수백 가지라 규칙+비용 최소 탐색으로 충분하다.
|
|
3
|
+
// 두 층으로 지킨다 — ① 폭은 실제 글꼴 글리프 폭(opentype · satori 와 같은 계산)으로 잰다(어림 폭이 틀려 satori 가 임의 자리에서 접던 구멍을 막음) ② 고른 줄은 nowrap 행으로 그려 렌더러가 다시 접을 수 없게 한다(render.ts linesEl).
|
|
4
|
+
import { parse as parseFont } from '@shuding/opentype.js';
|
|
5
|
+
import { wlen } from './specs.js';
|
|
6
|
+
const registered = [];
|
|
7
|
+
const parsed = new Map();
|
|
8
|
+
/** 글꼴 등록 — render.loadFonts 가 satori 에 넘기는 목록을 그대로 넣는다(파싱은 첫 측정 때 굵기별로 게으르게) */
|
|
9
|
+
export function registerFonts(list) { for (const f of list)
|
|
10
|
+
if (!registered.some((r) => r.name === f.name && r.weight === f.weight))
|
|
11
|
+
registered.push(f); }
|
|
12
|
+
function fontOf(rec) {
|
|
13
|
+
if (parsed.has(rec))
|
|
14
|
+
return parsed.get(rec);
|
|
15
|
+
let f = null;
|
|
16
|
+
try {
|
|
17
|
+
f = parseFont(rec.data);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
f = null;
|
|
21
|
+
}
|
|
22
|
+
parsed.set(rec, f);
|
|
23
|
+
return f;
|
|
24
|
+
}
|
|
25
|
+
/** 어림 폭(글꼴이 없을 때만 · Pretendard 실측): 한글·전각 0.86em · 숫자·라틴 0.55em · 공백 0.3em */
|
|
26
|
+
export const estimateW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 : (ch.codePointAt(0) || 0) > 0x2e7f ? 0.86 : 0.55), 0) * fs;
|
|
27
|
+
/** 실제 글꼴 폭 — 굵기가 같은 글꼴을 등록 순서(Pretendard → JP → CJK → Thai)로 훑어 글리프가 있는 첫 글꼴의 advance 를 더한다(satori 폴백과 같은 순서). letterSpacing 은 satori 처럼 (글자 수−1)배 */
|
|
28
|
+
export function measureText(t, fs, o = {}) {
|
|
29
|
+
const weight = o.weight ?? 800;
|
|
30
|
+
const ls = o.letterSpacing ?? 0;
|
|
31
|
+
const chars = [...t];
|
|
32
|
+
if (!chars.length)
|
|
33
|
+
return 0;
|
|
34
|
+
const pool = registered.filter((r) => r.weight === weight);
|
|
35
|
+
const fonts = (pool.length ? pool : registered.filter((r) => r.weight === nearestWeight(weight))).map(fontOf).filter((f) => !!f);
|
|
36
|
+
if (!fonts.length)
|
|
37
|
+
return estimateW(t, fs) + ls * (chars.length - 1);
|
|
38
|
+
let w = 0;
|
|
39
|
+
for (const ch of chars) {
|
|
40
|
+
let done = false;
|
|
41
|
+
for (const f of fonts) {
|
|
42
|
+
const gi = f.charToGlyphIndex(ch);
|
|
43
|
+
if (gi > 0) {
|
|
44
|
+
w += ((f.glyphs.get(gi).advanceWidth ?? f.unitsPerEm * 0.5) / f.unitsPerEm) * fs;
|
|
45
|
+
done = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!done)
|
|
50
|
+
w += (ch === ' ' ? 0.3 : 0.86) * fs;
|
|
51
|
+
}
|
|
52
|
+
return w + ls * (chars.length - 1);
|
|
53
|
+
}
|
|
54
|
+
function nearestWeight(w) { const ws = [...new Set(registered.map((r) => r.weight))]; return ws.sort((a, b) => Math.abs(a - w) - Math.abs(b - w))[0] ?? w; }
|
|
55
|
+
export function hasMeasureFonts() { return registered.length > 0; }
|
|
56
|
+
// ── 한국어 결속 규칙 ────────────────────────────────────────────────────────────
|
|
57
|
+
/** 앞 어절에 붙어야 하는 어절(의존명사·후치사류) — 「색조 / 때문에」 금지 */
|
|
58
|
+
const KO_GLUE_PREV = new Set(['때문에', '때문이죠', '때문이에요', '때문입니다', '덕분에', '대신', '만큼', '정도', '이상', '이하', '미만', '이내', '동안', '사이', '이후', '이전', '전에', '후에', '위해', '위한', '통해', '대해', '대한', '관해', '관한', '따라', '처럼', '같이', '같은', '만에', '밖에', '뿐', '채', '척', '듯', '중', '등', '및', '또는', '혹은']);
|
|
59
|
+
/** 뒤 어절에 붙어야 하는 어절(관형사·부정/정도 부사) — 「이런 / 컬러」·「안 / 팔아요」 금지 */
|
|
60
|
+
const KO_GLUE_NEXT_STRONG = new Set(['이런', '그런', '저런', '어떤', '무슨', '모든', '여러', '다른', '각', '매', '약', '총', '무려', '단', '오직', '바로', '이', '그', '저', '새', '첫', '헌', '옛', '온', '안', '못', '한', '두', '세', '네', '몇', '여섯', '일곱', '여덟', '아홉', '열', '스무']);
|
|
61
|
+
const KO_GLUE_NEXT_WEAK = new Set(['더', '덜', '잘', '꼭', '딱', '아주', '매우', '정말', '진짜', '너무', '가장', '제일', '다시', '아직', '이미', '늘', '항상', '함께', '같이', '직접', '먼저', '왜', '어떻게', '뭐부터', '뭘']);
|
|
62
|
+
/** 절이 끝나는 연결어미 — 뒤에서 끊기 좋음(「컬러 못 골라서 / 3분째 고민」) */
|
|
63
|
+
// 🔴 「고·서·면·자」 한 글자 어미는 광고·순서·라면·사용자 같은 명사와 겹쳐 쓰지 않는다
|
|
64
|
+
const KO_CLAUSE_END = /(지만|는데|인데|니까|라서|려고|도록|다가|든지|거나|라면|다면|더니|어서|아서|해서|면서|으면|이면|하고|이고|하며|이며|하면|더라도)$/;
|
|
65
|
+
/** 부사격·보조사로 끝나는 어절 — 뒤에서 끊기 좋음(「항공부터 / 렌터카까지 / 한 곳에서」) */
|
|
66
|
+
const KO_ADVERBIAL_END = /(부터|까지|에서|에게|께|으로|로|처럼|보다|마다|조차|마저|밖에|에는|엔|에도|로도|으로도|에서도|께서|한테|더러|에서는)$/;
|
|
67
|
+
/** 주제·주격·목적격·관형형 등 — 약한 우선 */
|
|
68
|
+
const KO_MARKER_END = /(은|는|이|가|을|를|도|만|던)$/;
|
|
69
|
+
/** 소유격 「의」 뒤 끊기 억제(「오늘의 / 입술」) — 강의·회의·문의처럼 「의」로 끝나는 명사는 제외 */
|
|
70
|
+
const KO_GENITIVE_END = /[가-힣]의$/;
|
|
71
|
+
const KO_UI_NOUNS = new Set(['강의', '회의', '문의', '정의', '주의', '의의', '편의', '호의', '심의', '논의', '합의', '동의', '상의', '토의', '협의', '예의', '유의', '용의', '결의', '건의', '의']);
|
|
72
|
+
const EN_NO_END = new Set(['the', 'a', 'an', 'of', 'to', 'in', 'on', 'for', 'with', 'and', 'or', 'but', 'your', 'our', 'my', 'its', 'their', 'not', 'just', 'at', 'by', 'from', 'into', 'is', 'are', 'be', 'that', 'this', 'very', 'so', 'you\'re', 'we\'re']);
|
|
73
|
+
const EN_NO_START = new Set(['too', 'either', 'yet']);
|
|
74
|
+
const isHangul = (s) => /[가-힣]/.test(s);
|
|
75
|
+
const isCJKNoSpace = (s) => /[-ヿ㐀-鿿]/.test(s) && !/[가-힣]/.test(s);
|
|
76
|
+
const PUNCT_END = /[,,、.!?!?::·—–\-…」』’”))]$/;
|
|
77
|
+
const HARD_PUNCT_END = /[,,、.!?!?::·…]$/;
|
|
78
|
+
/** 어절 토큰화 + 한 글자 한글 어절 결속 — 「한 번」「안 팔아요」「수 있어요」「이 동네」는 한 덩어리 */
|
|
79
|
+
export function tokenize(text) {
|
|
80
|
+
const raw = String(text || '').replace(/([,,、!?!?.])(?=[^\s\d,.!?])/g, '$1 ').trim().split(/\s+/).filter(Boolean);
|
|
81
|
+
const words = [];
|
|
82
|
+
for (let i = 0; i < raw.length; i++) {
|
|
83
|
+
const w = raw[i];
|
|
84
|
+
if ([...w].length === 1 && isHangul(w)) {
|
|
85
|
+
if (i + 1 < raw.length) {
|
|
86
|
+
words.push(w + ' ' + raw[i + 1]);
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (words.length) {
|
|
91
|
+
words[words.length - 1] += ' ' + w;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
words.push(w);
|
|
96
|
+
}
|
|
97
|
+
return words;
|
|
98
|
+
}
|
|
99
|
+
const lastWord = (tok) => tok.split(' ').pop() || tok;
|
|
100
|
+
const firstWord = (tok) => tok.split(' ')[0] || tok;
|
|
101
|
+
/** 어절 i 뒤에서 끊는 비용(음수=좋은 자리) — i 는 앞 줄의 마지막 어절, next 는 다음 줄의 첫 어절 */
|
|
102
|
+
export function breakCost(prev, next) {
|
|
103
|
+
const a = lastWord(prev), b = firstWord(next);
|
|
104
|
+
const al = a.toLowerCase().replace(/[^a-z']/g, ''), bl = b.toLowerCase().replace(/[^a-z']/g, '');
|
|
105
|
+
let c = 0;
|
|
106
|
+
if (HARD_PUNCT_END.test(a))
|
|
107
|
+
c -= 0.6;
|
|
108
|
+
else if (PUNCT_END.test(a))
|
|
109
|
+
c -= 0.3;
|
|
110
|
+
else if (isHangul(a)) {
|
|
111
|
+
if (KO_GENITIVE_END.test(a) && !KO_UI_NOUNS.has(a))
|
|
112
|
+
c += 0.3;
|
|
113
|
+
else if (KO_CLAUSE_END.test(a))
|
|
114
|
+
c -= 0.3;
|
|
115
|
+
else if (KO_ADVERBIAL_END.test(a))
|
|
116
|
+
c -= 0.25;
|
|
117
|
+
else if (KO_MARKER_END.test(a))
|
|
118
|
+
c -= 0.12;
|
|
119
|
+
}
|
|
120
|
+
else if (al && EN_NO_END.has(al))
|
|
121
|
+
c += 0.45;
|
|
122
|
+
if (isHangul(b) || isHangul(a)) {
|
|
123
|
+
if (KO_GLUE_PREV.has(b))
|
|
124
|
+
c += 0.8;
|
|
125
|
+
if (KO_GLUE_NEXT_STRONG.has(a))
|
|
126
|
+
c += 0.8;
|
|
127
|
+
else if (KO_GLUE_NEXT_WEAK.has(a))
|
|
128
|
+
c += 0.3;
|
|
129
|
+
}
|
|
130
|
+
if (bl && EN_NO_START.has(bl))
|
|
131
|
+
c += 0.3;
|
|
132
|
+
// 숫자·단위가 갈라지는 자리(「82,700 / 원」·「3 / 분」) 는 tokenize 가 붙이지 않으므로 여기서 막는다
|
|
133
|
+
if (/\d$/.test(a) && /^[%원명개분초시년월일건회번배]/.test(b))
|
|
134
|
+
c += 0.9;
|
|
135
|
+
return c;
|
|
136
|
+
}
|
|
137
|
+
/** 줄나눔 — 한 줄에 들어가면 그대로 · 아니면 줄 수를 2부터 늘려가며 모든 어절 경계 조합 중 비용 최소(결속 규칙 + 줄 균형 + 고아 벌점)를 고른다. 어떤 조합도 폭 안에 못 들어가면(한 어절이 줄보다 김) 뒤에서부터 채운 줄을 돌려주고 linesEl 이 글자를 줄인다. */
|
|
138
|
+
export function breakLines(text, fs, maxW, o = {}) {
|
|
139
|
+
const opt = typeof o === 'number' ? { maxLines: o } : o;
|
|
140
|
+
const maxLines = opt.maxLines ?? 3;
|
|
141
|
+
const limit = maxW * (1 - (opt.margin ?? 0.02));
|
|
142
|
+
const W = (s) => measureText(s, fs, { weight: opt.weight, letterSpacing: opt.letterSpacing });
|
|
143
|
+
const src = String(text || '').trim();
|
|
144
|
+
if (!src)
|
|
145
|
+
return [];
|
|
146
|
+
if (isCJKNoSpace(src) && !src.includes(' '))
|
|
147
|
+
return breakCJK(src, W, limit, maxLines);
|
|
148
|
+
const words = tokenize(src);
|
|
149
|
+
if (!words.length)
|
|
150
|
+
return [];
|
|
151
|
+
if (words.length === 1 || W(words.join(' ')) <= limit)
|
|
152
|
+
return [words.join(' ')];
|
|
153
|
+
const widths = new Map();
|
|
154
|
+
const lineW = (from, to) => { const k = `${from}:${to}`; let v = widths.get(k); if (v === undefined) {
|
|
155
|
+
v = W(words.slice(from, to).join(' '));
|
|
156
|
+
widths.set(k, v);
|
|
157
|
+
} return v; };
|
|
158
|
+
// 고아 — 한글은 짧은 한 어절(≤2자)만 · 라틴은 한 단어 줄 전부(「… Main / Character」)
|
|
159
|
+
const orphan = (from, to) => to - from === 1 && (isHangul(words[from]) ? wlen(words[from].replace(/ /g, '')) <= 4 : true);
|
|
160
|
+
const n = words.length;
|
|
161
|
+
// 줄 수 k 를 2부터 늘려가며 조합 비용 최소를 찾는다 · 줄이 하나 늘 때마다 +EXTRA_LINE(결속 위반(+0.8)을 무릅쓴 두 줄보다 깨끗한 세 줄을 고르되, 균형만 조금 나은 두 줄은 그대로) · maxLines 안에서 답이 없으면 그 밖에서도 찾고 linesEl 이 글자를 줄인다
|
|
162
|
+
const EXTRA_LINE = 0.7;
|
|
163
|
+
let global = null;
|
|
164
|
+
for (let k = 2; k <= Math.min(n, maxLines + 2); k++) {
|
|
165
|
+
if (global && k > maxLines)
|
|
166
|
+
break;
|
|
167
|
+
let best = null;
|
|
168
|
+
const rec = (start, cutsSoFar, cost) => {
|
|
169
|
+
const linesLeft = k - cutsSoFar.length;
|
|
170
|
+
if (linesLeft === 1) {
|
|
171
|
+
const w = lineW(start, n);
|
|
172
|
+
if (w > limit)
|
|
173
|
+
return;
|
|
174
|
+
const cuts = [...cutsSoFar, n];
|
|
175
|
+
const ws = cuts.map((c, i) => lineW(i ? cuts[i - 1] : 0, c));
|
|
176
|
+
const mean = ws.reduce((a, b) => a + b, 0) / ws.length;
|
|
177
|
+
// 줄 균형(폭 편차 합 / 한도) — 세 줄 이상(본문)은 결속 보너스가 균형을 이기지 않게 1.5배(「가벼운 발림으로 / 하루 종일 … / …」 첫 줄만 짧은 모양 방지)
|
|
178
|
+
let total = cost + (ws.reduce((a, b) => a + Math.abs(b - mean), 0) / limit) * (k >= 3 ? 1.5 : 1);
|
|
179
|
+
// 고아: 마지막(또는 첫) 줄 → 강한 벌점 · 가운데 줄은 중간 벌점
|
|
180
|
+
cuts.forEach((c, i) => { const from = i ? cuts[i - 1] : 0; if (orphan(from, c))
|
|
181
|
+
total += i === 0 || i === cuts.length - 1 ? 1 : 0.6; });
|
|
182
|
+
// 뒤 줄이 앞 줄보다 눈에 띄게 길면(역삼각형) 살짝 벌점 — 「오늘의 입술, / 틴트 한 번」 처럼 위가 길거나 비슷한 모양 선호
|
|
183
|
+
for (let i = 1; i < ws.length; i++)
|
|
184
|
+
if (ws[i] - ws[i - 1] > limit * 0.15)
|
|
185
|
+
total += 0.08;
|
|
186
|
+
if (!best || total < best.cost)
|
|
187
|
+
best = { cuts, cost: total };
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (let cut = start + 1; cut <= n - (linesLeft - 1); cut++) {
|
|
191
|
+
if (lineW(start, cut) > limit)
|
|
192
|
+
break;
|
|
193
|
+
rec(cut, [...cutsSoFar, cut], cost + breakCost(words[cut - 1], words[cut]));
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
rec(0, [], 0);
|
|
197
|
+
if (best) {
|
|
198
|
+
const b = best;
|
|
199
|
+
const total = b.cost + EXTRA_LINE * (k - 2);
|
|
200
|
+
if (!global || total < global.cost)
|
|
201
|
+
global = { cuts: b.cuts, cost: total, k };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (global) {
|
|
205
|
+
const g = global;
|
|
206
|
+
return g.cuts.map((c, i) => words.slice(i ? g.cuts[i - 1] : 0, c).join(' '));
|
|
207
|
+
}
|
|
208
|
+
// 못 맞추는 경우(한 어절이 줄보다 김) — 뒤에서부터 채워 마지막 줄 고아를 피하고, 넘치는 줄은 linesEl 이 글자를 줄인다 · 🔴 어절을 버리지 않는다(예전엔 maxLines 로 잘라 뒷말이 사라졌다)
|
|
209
|
+
const lines = [];
|
|
210
|
+
let cur = [];
|
|
211
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
212
|
+
const next = [words[i], ...cur];
|
|
213
|
+
if (cur.length && W(next.join(' ')) > limit) {
|
|
214
|
+
lines.unshift(cur);
|
|
215
|
+
cur = [words[i]];
|
|
216
|
+
}
|
|
217
|
+
else
|
|
218
|
+
cur = next;
|
|
219
|
+
}
|
|
220
|
+
if (cur.length)
|
|
221
|
+
lines.unshift(cur);
|
|
222
|
+
return lines.map((l) => l.join(' '));
|
|
223
|
+
}
|
|
224
|
+
/** 일본어·중국어(띄어쓰기 없음) — 글자 사이 어디서든 끊되 금칙(줄 머리에 。、」ー っ 작은 가나 · 줄 끝에 「() 을 지키고 구두점·조사 뒤를 우선, 줄 균형으로 고른다 */
|
|
225
|
+
function breakCJK(src, W, limit, maxLines) {
|
|
226
|
+
const chars = [...src];
|
|
227
|
+
if (W(src) <= limit)
|
|
228
|
+
return [src];
|
|
229
|
+
// 금칙 + 조사(は・が・を・に・で・と・へ・も・の) 는 줄 머리에 두지 않고 그 뒤에서 끊는다 · 가타카나 낱말(ティント) 안은 끊지 않고 문자 종류가 바뀌는 자리를 우선
|
|
230
|
+
const noStart = /[。、,.・ー〜っゃゅょぁぃぅぇぉ」』)】!?!?はがをにでとへもの]/;
|
|
231
|
+
const noEnd = /[「『(【]/;
|
|
232
|
+
const goodAfter = /[。、,!?!?]/;
|
|
233
|
+
const particle = /[はがをにでとへもの]/;
|
|
234
|
+
const kata = /[\u30a0-\u30ff]/;
|
|
235
|
+
const cost = (i) => { const a = chars[i - 1], b = chars[i]; if (noStart.test(b) || noEnd.test(a))
|
|
236
|
+
return Infinity; if (goodAfter.test(a))
|
|
237
|
+
return -0.6; if (particle.test(a))
|
|
238
|
+
return /[\u3040-\u309f]/.test(b) ? 0.1 : -0.25 /* 조사 뒤가 히라가나면(ひ「と」つ) 낱말 속일 수 있어 보너스 없음 */; if (kata.test(a) && kata.test(b))
|
|
239
|
+
return 0.6; if (kata.test(a) !== kata.test(b))
|
|
240
|
+
return -0.2; return 0.35; /* 낱말 가운데일 가능성 — 조사·구두점·문자 종류 경계가 아닌 자리는 되도록 피한다 */ };
|
|
241
|
+
const n = chars.length;
|
|
242
|
+
for (let k = 2; k <= maxLines; k++) {
|
|
243
|
+
let best = null;
|
|
244
|
+
const rec = (start, cuts, c) => {
|
|
245
|
+
const left = k - cuts.length;
|
|
246
|
+
if (left === 1) {
|
|
247
|
+
const all = [...cuts, n];
|
|
248
|
+
const ws = all.map((x, i) => W(chars.slice(i ? all[i - 1] : 0, x).join('')));
|
|
249
|
+
if (ws.some((w) => w > limit))
|
|
250
|
+
return;
|
|
251
|
+
const mean = ws.reduce((a, b) => a + b, 0) / ws.length;
|
|
252
|
+
const total = c + ws.reduce((a, b) => a + Math.abs(b - mean), 0) / limit;
|
|
253
|
+
if (!best || total < best.cost)
|
|
254
|
+
best = { cuts: all, cost: total };
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
for (let cut = start + 1; cut <= n - (left - 1); cut++) {
|
|
258
|
+
if (W(chars.slice(start, cut).join('')) > limit)
|
|
259
|
+
break;
|
|
260
|
+
const bc = cost(cut);
|
|
261
|
+
if (bc === Infinity)
|
|
262
|
+
continue;
|
|
263
|
+
rec(cut, [...cuts, cut], c + bc);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
rec(0, [], 0);
|
|
267
|
+
if (best) {
|
|
268
|
+
const b = best;
|
|
269
|
+
return b.cuts.map((x, i) => chars.slice(i ? b.cuts[i - 1] : 0, x).join(''));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const out = [];
|
|
273
|
+
let cur = '';
|
|
274
|
+
for (const ch of chars) {
|
|
275
|
+
if (cur && W(cur + ch) > limit) {
|
|
276
|
+
out.push(cur);
|
|
277
|
+
cur = ch;
|
|
278
|
+
}
|
|
279
|
+
else
|
|
280
|
+
cur += ch;
|
|
281
|
+
}
|
|
282
|
+
if (cur)
|
|
283
|
+
out.push(cur);
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
@@ -72,7 +72,8 @@ export type CreativePlan = {
|
|
|
72
72
|
imagesKrw: number;
|
|
73
73
|
videosKrw: number;
|
|
74
74
|
totalKrw: number;
|
|
75
|
-
chargedKrw: number;
|
|
75
|
+
chargedKrw: number; /** 결함·끊김 재생성이 다 붙었을 때 상한(포맷당 예상 원가의 2배) */
|
|
76
|
+
maxChargedKrw: number;
|
|
76
77
|
lines: string[];
|
|
77
78
|
};
|
|
78
79
|
explain: string;
|
|
@@ -80,6 +81,8 @@ export type CreativePlan = {
|
|
|
80
81
|
/** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
|
|
81
82
|
export declare const CREATIVE_MARKUP = 1.1;
|
|
82
83
|
export declare const chargeFor: (costKrw: number) => number;
|
|
84
|
+
/** 재생성 상한 — 한 영상(포맷×언어)의 생성 원가가 예상 단가의 이 배수를 넘으면 더 다시 만들지 않고 그 테이크를 쓴다(광고주 차감 보호) */
|
|
85
|
+
export declare const RETAKE_CAP_RATIO = 2;
|
|
83
86
|
/** 달러 → 원. BizRouter 모델표가 쓰는 환율(1436)과 맞춘다 · 환경변수로 덮어쓸 수 있다 */
|
|
84
87
|
export declare const USD_KRW: () => number;
|
|
85
88
|
export declare const IMAGE_MODELS: Record<Tier, {
|
|
@@ -2,6 +2,8 @@ import { pickFormats, playbookFor, sceneCuts } from './playbook.js';
|
|
|
2
2
|
/** 청구 = 원가 × 1.1 (사장님 정의 · 원 단위 올림) */
|
|
3
3
|
export const CREATIVE_MARKUP = 1.1;
|
|
4
4
|
export const chargeFor = (costKrw) => Math.ceil(Math.max(0, costKrw) * CREATIVE_MARKUP);
|
|
5
|
+
/** 재생성 상한 — 한 영상(포맷×언어)의 생성 원가가 예상 단가의 이 배수를 넘으면 더 다시 만들지 않고 그 테이크를 쓴다(광고주 차감 보호) */
|
|
6
|
+
export const RETAKE_CAP_RATIO = 2;
|
|
5
7
|
/** 달러 → 원. BizRouter 모델표가 쓰는 환율(1436)과 맞춘다 · 환경변수로 덮어쓸 수 있다 */
|
|
6
8
|
export const USD_KRW = () => Number(process.env.USD_KRW || 1436);
|
|
7
9
|
export const IMAGE_MODELS = {
|
|
@@ -93,9 +95,11 @@ export function resolveCreativePlan(spec, ctx) {
|
|
|
93
95
|
lines.push('이미지 생성 없이 브랜드 색 배경 + 문구로 만들어요(비용 0)');
|
|
94
96
|
if (conceptsWithVideo)
|
|
95
97
|
lines.push(`영상 ${conceptsWithVideo * perConcept.length}편(방향 ${conceptsWithVideo} × 포맷 ${perConcept.map((v) => { const k = sceneCuts(v.format || 'cinematic', v.durationSec, tone); return `${FORMAT_LABEL_SHORT[v.format || 'cinematic']} ${v.durationSec}초${k > 1 ? `(${k}컷)` : ''}`; }).join('·')}) ≈ ₩${videosKrw.toLocaleString()}${perConcept.some((v) => v.audio) ? ' · 소리 포함' : ''} · 4:5·1:1 파생 무료`);
|
|
96
|
-
|
|
98
|
+
// 재생성(손가락 결함·대사 끊김·정지 화면)도 광고주 크레딧에서 차감된다 → 미리 상한을 알린다(포맷당 예상 원가의 2배 · factory 의 RETAKE_CAP_RATIO 와 같은 수) · 2026-09-17 사장님
|
|
99
|
+
const maxChargedKrw = chargeFor(imagesKrw + videosKrw * RETAKE_CAP_RATIO);
|
|
100
|
+
lines.push(`예상 원가 ₩${totalKrw.toLocaleString()} → 청구 ₩${chargeFor(totalKrw).toLocaleString()}(원가의 1.1배 · 실제 생성된 것만, 만들어진 뒤 정확한 원가로 차감)${conceptsWithVideo ? ` · 영상 결함을 다시 만들면 최대 ₩${maxChargedKrw.toLocaleString()}까지(영상마다 예상의 2배 상한)` : ''}`);
|
|
97
101
|
const explain = [`소재 형태: ${MODE_LABEL[mode]} · 출처: ${SOURCE_LABEL[source]} · 톤: ${tone} · 업종 플레이북: ${pb.label}`, ...lines].join('\n');
|
|
98
|
-
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 };
|
|
102
|
+
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), maxChargedKrw, lines }, explain };
|
|
99
103
|
}
|
|
100
104
|
/** 9:16 원본에서 무료로 파생할 비율(메타 피드 4:5·1:1) */
|
|
101
105
|
export const DERIVED_RATIOS = { '9x16': ['4x5', '1x1'], '16x9': ['1x1'], '1x1': [], '4x5': [] };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Brief, Concept, CreativeAsset } from '../state.js';
|
|
2
2
|
import { type Size } from './specs.js';
|
|
3
|
+
export { breakLines, measureText } from './linebreak.js';
|
|
3
4
|
export declare const FONT_FAMILY = "Pretendard, PretendardJP, NotoCJKtc, NotoCJKsc, NotoThai";
|
|
4
5
|
export declare function loadFonts(lang?: string): Promise<{
|
|
5
6
|
name: string;
|
|
@@ -20,13 +21,10 @@ export declare function onColor(hex: string): string;
|
|
|
20
21
|
/** 어절 단위 줄바꿈 — satori 는 숫자·라틴과 한글 사이에서 줄을 바꾼다(「300명도」→「300 / 명도」 · 2026-09-17 사장님 지적). 공백으로 나눈 어절을 nowrap 조각으로 감싸고 flex-wrap 으로 흐르게 한다. */
|
|
21
22
|
/** 어절 경계에서 자르기 — 본문이 한도에서 「아이템이에」처럼 낱말 중간에 끊기지 않게(2026-09-17 가로 이미지 검수). 문장부호 끝 정리 */
|
|
22
23
|
export declare function cutWords(text: string, limit: number): string;
|
|
23
|
-
/** 글자 폭
|
|
24
|
-
export declare const textW: (t: string, fs: number) => number;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
export declare function breakLines(text: string, fs: number, maxW: number, maxLines?: number): string[];
|
|
28
|
-
/** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
|
|
29
|
-
export declare function linesEl(text: string, style: Record<string, unknown>, maxW: number, align?: 'left' | 'center'): El;
|
|
24
|
+
/** 글자 폭 — 실제 글꼴 글리프 폭(linebreak.measureText · 굵기 700 기준 · 글꼴 미등록이면 어림). 가로 포스터 세로 예산·알약 폭 계산에 쓴다 */
|
|
25
|
+
export declare const textW: (t: string, fs: number, weight?: number) => number;
|
|
26
|
+
/** 줄나눔이 계산된 글줄 — 줄마다 nowrap 행으로 그려 satori 가 다른 자리에서 접을 수 없게 한다(한 줄이어도 nowrap). 어절 하나가 줄보다 길어 어떤 조합도 폭 안에 못 들어가면 가장 넓은 줄이 들어가도록 글자를 줄인다(최소 0.7배 · 그 아래는 글자 단위 줄바꿈). */
|
|
27
|
+
export declare function linesEl(text: string, style: Record<string, unknown>, maxW: number, align?: 'left' | 'center', maxLines?: number): El;
|
|
30
28
|
export declare function wordsEl(text: string, style: Record<string, unknown>, align?: 'left' | 'center'): El;
|
|
31
29
|
/** 한 규격의 트리 */
|
|
32
30
|
export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
|
|
@@ -7,6 +7,8 @@ import satori from 'satori';
|
|
|
7
7
|
import { Resvg } from '@resvg/resvg-js';
|
|
8
8
|
import { HOME } from '../state.js';
|
|
9
9
|
import { SIZES, wlen, policyIssues } from './specs.js';
|
|
10
|
+
import { breakLines, measureText, registerFonts } from './linebreak.js';
|
|
11
|
+
export { breakLines, measureText } from './linebreak.js';
|
|
10
12
|
const FONT_CDN = 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard/dist/public/static';
|
|
11
13
|
const WEIGHTS = [['Pretendard-ExtraBold', 800], ['Pretendard-Bold', 700], ['Pretendard-Medium', 500]];
|
|
12
14
|
/** 언어별 글꼴 폴백 — Pretendard 는 한글·라틴만. 일본어=Pretendard JP(가나·한자) · 중국어=Noto Sans TC/SC · 태국어=Noto Sans Thai. 같은 family 이름으로 넣어 satori 가 빠진 글리프를 다음 글꼴에서 찾게 한다. */
|
|
@@ -60,6 +62,7 @@ export async function loadFonts(lang) {
|
|
|
60
62
|
out.push({ name: family, data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), weight: weight, style: 'normal' });
|
|
61
63
|
}
|
|
62
64
|
fontCache.set(key, out);
|
|
65
|
+
registerFonts(out);
|
|
63
66
|
return out;
|
|
64
67
|
}
|
|
65
68
|
/** 🔴 확장자가 아니라 바이트로 MIME 판별 — Gemini 이미지 API 는 .png 요청에도 JPEG 를 돌려준다(2026-09-16 실측 · 잘못된 MIME 이면 satori 가 배경을 조용히 버림) */
|
|
@@ -107,67 +110,31 @@ export function cutWords(text, limit) {
|
|
|
107
110
|
const sp = cut.lastIndexOf(' ');
|
|
108
111
|
return (sp > limit * 0.5 ? cut.slice(0, sp) : t.slice(0, limit)).replace(/[\s,·:;\-—]+$/, '');
|
|
109
112
|
}
|
|
110
|
-
/** 글자 폭
|
|
111
|
-
export const textW = (t, fs
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
words.push(raw[i]);
|
|
126
|
-
}
|
|
127
|
-
if (!words.length)
|
|
128
|
-
return [];
|
|
129
|
-
const W = (ws) => textW(ws.join(' '), fs);
|
|
130
|
-
if (words.length === 1 || W(words) <= maxW)
|
|
131
|
-
return [words.join(' ')];
|
|
132
|
-
const orphan = (ws) => ws.length === 1 && wlen(ws[0].replace(' ', '')) <= 4;
|
|
133
|
-
let best = null;
|
|
134
|
-
for (let i = 1; i < words.length; i++) {
|
|
135
|
-
const a = words.slice(0, i), b = words.slice(i);
|
|
136
|
-
const wa = W(a), wb = W(b);
|
|
137
|
-
if (wa > maxW || wb > maxW)
|
|
138
|
-
continue;
|
|
139
|
-
let cost = Math.abs(wa - wb) / maxW;
|
|
140
|
-
if (/[,,、.!?!?:·—-]$/.test(a[a.length - 1]))
|
|
141
|
-
cost -= 0.5;
|
|
142
|
-
if (orphan(b) || orphan(a))
|
|
143
|
-
cost += 1;
|
|
144
|
-
if (!best || cost < best.cost)
|
|
145
|
-
best = { lines: [a.join(' '), b.join(' ')], cost };
|
|
113
|
+
/** 글자 폭 — 실제 글꼴 글리프 폭(linebreak.measureText · 굵기 700 기준 · 글꼴 미등록이면 어림). 가로 포스터 세로 예산·알약 폭 계산에 쓴다 */
|
|
114
|
+
export const textW = (t, fs, weight = 700) => measureText(t, fs, { weight });
|
|
115
|
+
/** 줄나눔이 계산된 글줄 — 줄마다 nowrap 행으로 그려 satori 가 다른 자리에서 접을 수 없게 한다(한 줄이어도 nowrap). 어절 하나가 줄보다 길어 어떤 조합도 폭 안에 못 들어가면 가장 넓은 줄이 들어가도록 글자를 줄인다(최소 0.7배 · 그 아래는 글자 단위 줄바꿈). */
|
|
116
|
+
export function linesEl(text, style, maxW, align = 'left', maxLines = 3) {
|
|
117
|
+
let fs = Number(style.fontSize || 16);
|
|
118
|
+
const weight = Number(style.fontWeight || 800);
|
|
119
|
+
const letterSpacing = Number(style.letterSpacing || 0);
|
|
120
|
+
let lines = breakLines(text, fs, maxW, { maxLines, weight, letterSpacing });
|
|
121
|
+
if (!lines.length)
|
|
122
|
+
return h('div', {}, '');
|
|
123
|
+
// 줄 수가 한도를 넘으면(긴 헤드라인) 어절을 버리지 않고 글자를 조금씩 줄여 한도 안으로(최소 0.6배)
|
|
124
|
+
const fs0 = fs;
|
|
125
|
+
while (lines.length > maxLines && fs > fs0 * 0.6) {
|
|
126
|
+
fs = Math.floor(fs * 0.92);
|
|
127
|
+
lines = breakLines(text, fs, maxW, { maxLines, weight, letterSpacing });
|
|
146
128
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (let i = words.length - 1; i >= 0; i--) {
|
|
153
|
-
const next = [words[i], ...cur];
|
|
154
|
-
if (cur.length && W(next) > maxW) {
|
|
155
|
-
lines.unshift(cur);
|
|
156
|
-
cur = [words[i]];
|
|
157
|
-
}
|
|
129
|
+
const widest = Math.max(...lines.map((l) => measureText(l, fs, { weight, letterSpacing })));
|
|
130
|
+
if (widest > maxW) {
|
|
131
|
+
const shrunk = Math.floor(fs * maxW / widest);
|
|
132
|
+
if (shrunk >= fs * 0.7)
|
|
133
|
+
fs = shrunk;
|
|
158
134
|
else
|
|
159
|
-
|
|
135
|
+
return wordsEl(text, style, align); // 그래도 안 들어가면(띄어쓰기 없는 긴 어절) 렌더러 줄바꿈에 맡긴다
|
|
160
136
|
}
|
|
161
|
-
|
|
162
|
-
lines.unshift(cur);
|
|
163
|
-
return lines.slice(0, maxLines).map((l) => l.join(' '));
|
|
164
|
-
}
|
|
165
|
-
/** 줄나눔이 계산된 헤드라인 — 줄마다 nowrap 행 */
|
|
166
|
-
export function linesEl(text, style, maxW, align = 'left') {
|
|
167
|
-
const lines = breakLines(text, Number(style.fontSize || 16), maxW);
|
|
168
|
-
if (lines.length <= 1)
|
|
169
|
-
return wordsEl(text, style, align);
|
|
170
|
-
return h('div', { display: 'flex', flexDirection: 'column', alignItems: align === 'center' ? 'center' : 'flex-start', ...style }, lines.map((l) => h('div', { whiteSpace: 'nowrap' }, l)));
|
|
137
|
+
return h('div', { display: 'flex', flexDirection: 'column', alignItems: align === 'center' ? 'center' : 'flex-start', ...style, fontSize: fs }, lines.map((l) => h('div', { whiteSpace: 'nowrap' }, l)));
|
|
171
138
|
}
|
|
172
139
|
export function wordsEl(text, style, align = 'left') {
|
|
173
140
|
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
@@ -271,7 +238,8 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
271
238
|
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', {}, '')]),
|
|
272
239
|
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 }, [
|
|
273
240
|
linesEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }, isWide ? Math.round(w * 0.72) : w - pad * 2),
|
|
274
|
-
|
|
241
|
+
// 본문도 같은 규칙으로 균형 있게(「… 분들께 / 추천해요.」 한 어절 고아 방지 · 최대 4줄 · 배너는 폭이 좁아 렌더러 줄바꿈)
|
|
242
|
+
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : banner ? wordsEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }) : linesEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }, isWide ? Math.round(w * 0.72) : w - pad * 2, 'left', 4),
|
|
275
243
|
...(chips ? [chips] : []),
|
|
276
244
|
h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
|
|
277
245
|
]),
|
|
@@ -334,7 +302,7 @@ export async function renderOverlay(file, w, hh, text, o) {
|
|
|
334
302
|
const fs1 = Math.round(base * (o.kind === 'cta' ? 0.055 : 0.068));
|
|
335
303
|
const pill = o.kind === 'cta'
|
|
336
304
|
? 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)
|
|
337
|
-
: 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) }, [
|
|
305
|
+
: 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) }, [linesEl(text, { fontSize: fs1, fontWeight: 800, color: '#fff', lineHeight: 1.25, letterSpacing: -1 }, Math.round(w * 0.86) - Math.round(fs1 * 0.6) * 2, 'center', 2)]);
|
|
338
306
|
// 안전영역: 릴스·쇼츠 UI 가 하단 ~20%·우측 ~10% 를 가린다 → 자막은 상단 1/3(훅) 또는 하단 25~30% 선. 훅은 크고 브랜드색 하이라이트.
|
|
339
307
|
const hook = o.kind === 'hook';
|
|
340
308
|
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)' }, [linesEl(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)' }, Math.round(w * 0.86) - Math.round(base * 0.05) * 2, 'center')]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adyou",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.17",
|
|
4
4
|
"description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"@resvg/resvg-js": "^2.6.2",
|
|
30
30
|
"commander": "^15.0.0",
|
|
31
31
|
"satori": "^0.33.4",
|
|
32
|
-
"zod": "^4.4.3"
|
|
32
|
+
"zod": "^4.4.3",
|
|
33
|
+
"@shuding/opentype.js": "1.4.0-beta.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@types/node": "^22",
|