adyou 0.6.3 → 0.6.4
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 +28 -3
- package/dist/core/creatives/qa.d.ts +2 -0
- package/dist/core/creatives/qa.js +28 -0
- package/dist/core/creatives/storyboard.d.ts +4 -1
- package/dist/core/creatives/storyboard.js +7 -4
- package/dist/core/creatives/videofx.d.ts +2 -0
- package/dist/core/creatives/videofx.js +5 -0
- package/package.json +1 -1
|
@@ -9,10 +9,10 @@ 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, motionScore, pickHookBand, probe, refCanvas, tailLoudnessDb, uiDemoClip } from './videofx.js';
|
|
12
|
+
import { appendEndCard, burnCaptions, concatClips, deriveRatio, hasFfmpeg, kenBurnsClip, lastFrame, 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
|
-
import { checkFrames, checkProductMatch, checkSpeech } from './qa.js';
|
|
15
|
+
import { checkArtifacts, checkFrames, checkProductMatch, checkSpeech } from './qa.js';
|
|
16
16
|
/** 말하는 포맷의 음성 방식 — 영어만 Veo 네이티브 발화(실측 안정) · 그 외 언어는 TTS 내레이션(Veo 한국어·일본어 발화는 엉뚱한 대사 · 2026-09-16 사장님 검수) */
|
|
17
17
|
export function speechModeFor(lang) { return lang.split('-')[0] === 'en' || hasSeedance() || !ttsAvailable() || isMockGen() ? 'native' : 'tts'; }
|
|
18
18
|
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
|
|
@@ -125,7 +125,8 @@ export async function produceConceptVideos(o) {
|
|
|
125
125
|
const mode = line ? speechModeFor(rl) : 'none';
|
|
126
126
|
// 대사는 클립 안에서 여유 있게 끝나야 한다 — 뚝 끊김 방지: 짧은 한 문장 · 마지막 1~1.5초는 말 없이 미소/끄덕임
|
|
127
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.` : '';
|
|
128
|
-
const
|
|
128
|
+
const castLine = board.cast ? ` Character (identical in every shot — same face, hair, skin tone, outfit): ${board.cast}.` : '';
|
|
129
|
+
const prompt = `${clip.prompt}${castLine}${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.`;
|
|
129
130
|
// 첫 프레임 규칙(2026-09-17 사장님 검수 「이미지 하나 붙여놓은 영상」) — 실물 제품 사진 + 제품 히어로 포맷일 때만 사진을 첫 프레임으로. 그 외는 프롬프트만으로 장면 생성(정적 배경 앵커 금지)
|
|
130
131
|
let firstFrame;
|
|
131
132
|
const refImg = format === 'product_hero' ? o.refs?.[0] : undefined;
|
|
@@ -137,6 +138,14 @@ export async function produceConceptVideos(o) {
|
|
|
137
138
|
}
|
|
138
139
|
catch { /* 없이 진행 */ }
|
|
139
140
|
}
|
|
141
|
+
// 인물·장소가 이어지는 컷은 앞 컷의 마지막 프레임에서 시작(컷 사이 인물 바뀜 방지 · 2026-09-17 에어서울 실사고)
|
|
142
|
+
if (!firstFrame && clip.continues && ci > 0 && ff && clipFiles[ci - 1] && fs.existsSync(clipFiles[ci - 1])) {
|
|
143
|
+
try {
|
|
144
|
+
firstFrame = await lastFrame(clipFiles[ci - 1], path.join(o.dir, `${key}_${rl}_c${ci}_from.png`));
|
|
145
|
+
log(' 앞 컷 마지막 프레임에서 이어서');
|
|
146
|
+
}
|
|
147
|
+
catch { /* 없이 진행 */ }
|
|
148
|
+
}
|
|
140
149
|
// 말하는 클립의 모델 — 영어 아닌 언어는 Seedance 네이티브 발화(립싱크) · 영어는 Veo
|
|
141
150
|
const clipModel = line && mode === 'native' ? speechVideoModel(rl, job.model) : job.model;
|
|
142
151
|
if (clipModel !== job.model)
|
|
@@ -189,6 +198,22 @@ export async function produceConceptVideos(o) {
|
|
|
189
198
|
}
|
|
190
199
|
}
|
|
191
200
|
}
|
|
201
|
+
// QA ①-b 비주얼 결함(중복 물체·손가락·얼굴 왜곡·불가능한 제품 형태) → 1회 재생성
|
|
202
|
+
if (ff && !isMockGen()) {
|
|
203
|
+
const art = await checkArtifacts(cf);
|
|
204
|
+
log(` ${art.note}`);
|
|
205
|
+
if (!art.ok) {
|
|
206
|
+
try {
|
|
207
|
+
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 });
|
|
208
|
+
costKrw += r4.costKrw;
|
|
209
|
+
const art2 = await checkArtifacts(cf);
|
|
210
|
+
log(` 다시 만든 컷 ${art2.note}`);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
log(` ⚠ 재생성 실패(첫 테이크 사용): ${e instanceof Error ? e.message.slice(0, 100) : e}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
192
217
|
// QA ②-0 움직임: 사실상 정지 화면이면 첫 프레임 없이 「역동적 카메라」로 1회 재생성(원가 추가)
|
|
193
218
|
if (ff && !isMockGen()) {
|
|
194
219
|
const mo = await motionScore(cf);
|
|
@@ -25,3 +25,5 @@ export declare function classifyRefImage(image: string): Promise<{
|
|
|
25
25
|
export declare function containsPerson(image: string): Promise<boolean | null>;
|
|
26
26
|
/** 자동 수집 이미지 중 「사람 없음 · 실물 제품 · 그래픽 아님」만(판정 불가면 제외 — 보수적) */
|
|
27
27
|
export declare function filterProductRefs(files: string[], log?: (s: string) => void): Promise<string[]>;
|
|
28
|
+
/** 비주얼 결함 — 3프레임을 qwen 비전에: 중복/이중 물체 · 손가락·팔 이상 · 얼굴 왜곡 · 물리적으로 불가능한 제품(쿠션에서 마스카라 브러시) · 깨진 글자. 실패면 재생성 대상 */
|
|
29
|
+
export declare function checkArtifacts(video: string): Promise<QaResult>;
|
|
@@ -156,3 +156,31 @@ export async function filterProductRefs(files, log) {
|
|
|
156
156
|
}
|
|
157
157
|
return out;
|
|
158
158
|
}
|
|
159
|
+
/** 비주얼 결함 — 3프레임을 qwen 비전에: 중복/이중 물체 · 손가락·팔 이상 · 얼굴 왜곡 · 물리적으로 불가능한 제품(쿠션에서 마스카라 브러시) · 깨진 글자. 실패면 재생성 대상 */
|
|
160
|
+
export async function checkArtifacts(video) {
|
|
161
|
+
const cfg = imageConfig();
|
|
162
|
+
if (!cfg || cfg.provider !== 'bizrouter')
|
|
163
|
+
return { ok: true, skipped: true, note: '결함 검사 건너뜀(키 없음)' };
|
|
164
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adp-qa-'));
|
|
165
|
+
try {
|
|
166
|
+
const p = await probe(video);
|
|
167
|
+
const frames = [];
|
|
168
|
+
for (const t of [0.6, p.durationSec / 2, Math.max(0.8, p.durationSec - 0.6)]) {
|
|
169
|
+
const f = path.join(dir, `f${frames.length}.jpg`);
|
|
170
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(2), '-i', video, '-frames:v', '1', '-vf', 'scale=540:-1', '-q:v', '4', f]);
|
|
171
|
+
frames.push(f);
|
|
172
|
+
}
|
|
173
|
+
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: 220, messages: [{ role: 'user', content: [{ type: 'text', text: 'These are 3 frames from an AI-generated short ad. Look for visual defects a viewer would notice: duplicated or doubled objects (e.g. two overlapping products/wands), extra or malformed fingers/limbs, warped or melting faces, physically impossible product forms (a mascara wand coming out of a compact, a bottle merging into a hand), floating parts, garbled letters. Ignore intentional stylization. Answer JSON only: {"defects": true|false, "severity": 0-3, "issues": ["<8 words each"]}' }, ...frames.map((f) => ({ type: 'image_url', image_url: { url: dataUrl(f) } }))] }] }), signal: AbortSignal.timeout(90_000) });
|
|
174
|
+
const j = (await res.json());
|
|
175
|
+
const m = /\{[\s\S]*\}/.exec(j.choices?.[0]?.message?.content || '');
|
|
176
|
+
const d = JSON.parse(m ? m[0] : '{}');
|
|
177
|
+
const bad = d.defects === true && (d.severity ?? 3) >= 2;
|
|
178
|
+
return { ok: !bad, note: bad ? `비주얼 결함: ${(d.issues || []).slice(0, 3).join(' · ')}` : `비주얼 검사 ✓${d.defects ? ` (경미: ${(d.issues || [])[0] || ''})` : ''}`, data: d };
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return { ok: true, skipped: true, note: `결함 검사 건너뜀(${e instanceof Error ? e.message.slice(0, 60) : e})` };
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -4,7 +4,8 @@ export type Clip = {
|
|
|
4
4
|
sec: number;
|
|
5
5
|
kind: 'gen' | 'ui_demo';
|
|
6
6
|
prompt: string;
|
|
7
|
-
speech?: string;
|
|
7
|
+
speech?: string; /** 앞 컷과 같은 인물·장소가 이어짐 → 앞 컷 마지막 프레임을 첫 프레임으로 */
|
|
8
|
+
continues?: boolean;
|
|
8
9
|
};
|
|
9
10
|
export type Storyboard = {
|
|
10
11
|
id: string;
|
|
@@ -20,6 +21,8 @@ export type Storyboard = {
|
|
|
20
21
|
score?: number;
|
|
21
22
|
scoreNote?: string;
|
|
22
23
|
lang: string;
|
|
24
|
+
/** 인물 외형 고정 문장(영어 · 모든 컷 프롬프트에 붙임 · 컷 사이 인물 바뀜 방지) */
|
|
25
|
+
cast?: string;
|
|
23
26
|
};
|
|
24
27
|
export type LocalizedBoard = {
|
|
25
28
|
hook: string;
|
|
@@ -7,7 +7,8 @@ import { FORMAT_DESC, genreStyle, playbookFor, TONE_WORDS } from './playbook.js'
|
|
|
7
7
|
import { fit, 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
|
-
clips: z.array(z.object({ sec: z.number(), kind: z.enum(['gen', 'ui_demo']).default('gen'), prompt: z.string(), speech: z.string().optional().nullable() })).min(1).max(3),
|
|
10
|
+
clips: z.array(z.object({ sec: z.number(), kind: z.enum(['gen', 'ui_demo']).default('gen'), prompt: z.string(), speech: z.string().optional().nullable(), continues: z.boolean().optional().nullable() })).min(1).max(3),
|
|
11
|
+
cast: z.string().optional().nullable(),
|
|
11
12
|
});
|
|
12
13
|
const LANG_NAME = { ko: 'Korean', en: 'English', ja: 'Japanese', 'zh-TW': 'Traditional Chinese', zh: 'Chinese', de: 'German', fr: 'French', es: 'Spanish', vi: 'Vietnamese', th: 'Thai', id: 'Indonesian', pt: 'Portuguese', it: 'Italian', nl: 'Dutch' };
|
|
13
14
|
export const langName = (l) => LANG_NAME[l] || LANG_NAME[l.split('-')[0]] || l;
|
|
@@ -60,8 +61,10 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
60
61
|
- ugc_selfie 클립: 인물이 스마트폰 셀카로 카메라를 보며 ${langName(lang)} 로 speech 를 말한다 — speech 는 **짧은 한 문장(영어 ≤ 12단어 · 한국어/일본어 ≤ 22자 · 말하면 4~5초)** 이어야 8초 안에 여유 있게 끝난다(뚝 끊김 금지) · 프롬프트에 "speaking to camera in ${langName(lang)}: '<speech>'" 형태로 포함 · 립싱크 자연스럽게 · 실제 후기 톤(대본 읽는 느낌 금지).
|
|
61
62
|
- ui_demo 클립(kind=ui_demo)은 prompt 대신 화면에 얹을 설명 한 줄(${lang})만 쓴다(실제 사이트 화면을 우리가 넣는다).
|
|
62
63
|
- text_hook 포맷의 clips[].prompt 는 「자막 뒤에 깔릴 배경 장면」이다 — 타겟이 일하는/사용하는 실사 장면(사람 뒷모습·손·사무실·제품 사용) 또는 추상 도형. 화면 안에 글자·숫자·차트 라벨·UI 를 그리지 말 것(우리 자막이 글자를 담당 · AI 글자는 깨진다).
|
|
64
|
+
- 사람이 나오는 board 는 cast 에 인물 외형 고정 문장(영어 1문장 · 성별/나이대/인종·피부톤/머리/옷/소품 · 브리프 타겟에 맞게)을 쓰고, 그 인물이 이어지는 컷은 clips[].continues=true(앞 컷의 마지막 프레임에서 이어짐 · 인물·장소 동일). 인물 없는 컷은 continues=false.
|
|
65
|
+
- 제품은 물리적으로 말이 되게 — 실제 제품 종류 하나만(쿠션에서 마스카라 브러시가 나오는 식 금지) · 한 컷에 한 제품 · 손과 제품 비율 자연스럽게.
|
|
63
66
|
- 사실은 브리프(usp·proofPoints·offer)에 있는 것만. 과장·최상급·보장·전후비교·개인 속성 지칭 금지. ${brief.notes ? `주의: ${brief.notes}` : ''}
|
|
64
|
-
- 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,clips:[{sec,kind,prompt,speech}]}]}`,
|
|
67
|
+
- 출력 JSON: {"boards":[{format,genre,hook,captions[],voice,music,cast,clips:[{sec,kind,prompt,speech,continues}]}]}`,
|
|
65
68
|
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 }),
|
|
66
69
|
});
|
|
67
70
|
const boards = [];
|
|
@@ -71,8 +74,8 @@ export async function generateStoryboards(brief, concept, o) {
|
|
|
71
74
|
boards.push(fallbackBoard(brief, concept, p.format, p.clips, genres[0], cta, lang, o.durationSec));
|
|
72
75
|
continue;
|
|
73
76
|
}
|
|
74
|
-
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 }; });
|
|
75
|
-
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: b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3), voice: b.voice || undefined, music: b.music || tone.music, cta, durationSec: o.durationSec, clips, lang });
|
|
77
|
+
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 }; });
|
|
78
|
+
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: b.captions.map((c) => c.trim()).filter(Boolean).slice(0, 3), voice: b.voice || undefined, music: b.music || tone.music, cta, durationSec: o.durationSec, clips, lang, cast: b.cast || undefined });
|
|
76
79
|
}
|
|
77
80
|
return boards;
|
|
78
81
|
}
|
|
@@ -76,3 +76,5 @@ export declare function refCanvas(ref: string, bgPng: string, output: string, di
|
|
|
76
76
|
}): Promise<string>;
|
|
77
77
|
/** 움직임 점수 — 1초·중간·끝 프레임(작게) 사이 평균 픽셀 차. 0~255 · 3 미만이면 사실상 정지 화면(「이미지 하나 붙여놓은 영상」) */
|
|
78
78
|
export declare function motionScore(video: string): Promise<number>;
|
|
79
|
+
/** 마지막 프레임 PNG — 다음 컷 첫 프레임(인물·장소 연속) */
|
|
80
|
+
export declare function lastFrame(video: string, output: string): Promise<string>;
|
|
@@ -205,3 +205,8 @@ export async function motionScore(video) {
|
|
|
205
205
|
return 99;
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
+
/** 마지막 프레임 PNG — 다음 컷 첫 프레임(인물·장소 연속) */
|
|
209
|
+
export async function lastFrame(video, output) {
|
|
210
|
+
await execFileP('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.15', '-i', video, '-frames:v', '1', '-update', '1', output]);
|
|
211
|
+
return output;
|
|
212
|
+
}
|
package/package.json
CHANGED