adyou 0.6.14 → 0.6.16
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.
|
@@ -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 });
|
|
@@ -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': [] };
|
|
@@ -112,7 +112,8 @@ export const textW = (t, fs) => [...t].reduce((n, ch) => n + (ch === ' ' ? 0.3 :
|
|
|
112
112
|
/** 헤드라인 줄나눔 — 「오늘의 입술, 틴트 한 / 번」·「나에게 맞는 컬러 / 찾기」처럼 넘치는 자리에서 끊겨 한 어절이 떨어지는 것을 막는다(2026-09-17 사장님).
|
|
113
113
|
* 규칙: 한 줄에 들어가면 그대로 · 두 줄이면 모든 어절 경계 후보 중 (① 쉼표·물음표·마침표·가운뎃점 뒤 우선 ② 두 줄 길이 균형 ③ 마지막 줄이 한 어절(≤4wlen)이면 강한 벌점 ④ 두 줄 모두 폭 안) 비용 최소 · 세 줄 이상은 뒤에서부터 채워 마지막 줄 고아를 피한다 */
|
|
114
114
|
export function breakLines(text, fs, maxW, maxLines = 3) {
|
|
115
|
-
|
|
115
|
+
// 쉼표·물음표 뒤에 띄어쓰기가 없으면(「분양정보,몇 개」) 어절이 붙어 엉뚱한 자리에서 나뉜다 → 문장부호 뒤 공백 정규화
|
|
116
|
+
const raw = String(text || '').replace(/([,,、!?!?.])(?=[^\s\d,.!?])/g, '$1 ').trim().split(/\s+/).filter(Boolean);
|
|
116
117
|
// 한 글자 어절(「한」「다」「그」)은 다음 어절과 한 덩어리 — 「틴트 한 / 번」 금지
|
|
117
118
|
const words = [];
|
|
118
119
|
for (let i = 0; i < raw.length; i++) {
|
|
@@ -15,7 +15,7 @@ const LANG_NAME = { ko: 'Korean', en: 'English', ja: 'Japanese', 'zh-TW': 'Tradi
|
|
|
15
15
|
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
|
-
const s = t.trim().replace(/\s+/g, ' ');
|
|
18
|
+
const s = t.trim().replace(/([,,、!?!?])(?=[^\s\d,.!?])/g, '$1 ').replace(/\s+/g, ' ');
|
|
19
19
|
if (wlen(s) <= 30)
|
|
20
20
|
return s;
|
|
21
21
|
// 🔴 한글도 어절 경계에서 — 글자 단위로 자르면 「현대건설이 짓는, 힐스테이」(힐스테이트 실사고 · 2026-09-17). 한도 15자(wlen 30) · 줄나눔은 렌더가 맡는다
|
package/package.json
CHANGED