aimakeall-mcp 0.1.0 → 0.3.0
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/README.md +1 -1
- package/lib/api-client.mjs +73 -1
- package/lib/cloud-tools.mjs +641 -2
- package/lib/companion-tools.mjs +50 -1
- package/lib/config.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
package/lib/api-client.mjs
CHANGED
|
@@ -139,5 +139,77 @@ export function createApiClient({ apiBase, pat }) {
|
|
|
139
139
|
return payload;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// SSE(text/event-stream) 라우트 — `data: {json}` 프레임을 순서대로 파싱한다.
|
|
143
|
+
// 서버는 게이트(인증/플랜/기계정책) 실패는 SSE 이전 일반 JSON 으로, 그 이후의
|
|
144
|
+
// 실패는 HTTP 200 스트림 안 {type:"error"} 프레임으로 보낸다. 둘 다 예외로 변환.
|
|
145
|
+
async function requestSse(pathName, { body, timeoutMs = DEFAULT_TIMEOUT_MS, onEvent } = {}) {
|
|
146
|
+
const response = await fetch(`${apiBase}${pathName}`, {
|
|
147
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${pat}`,
|
|
150
|
+
"X-AImakeAll-MCP-Version": MCP_PROXY_VERSION,
|
|
151
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
152
|
+
},
|
|
153
|
+
method: "POST",
|
|
154
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
155
|
+
});
|
|
156
|
+
const contentType = String(response.headers.get("content-type") || "");
|
|
157
|
+
if (!contentType.includes("text/event-stream")) {
|
|
158
|
+
const text = await response.text();
|
|
159
|
+
let payload = null;
|
|
160
|
+
try {
|
|
161
|
+
payload = text ? JSON.parse(text) : null;
|
|
162
|
+
} catch {
|
|
163
|
+
payload = null;
|
|
164
|
+
}
|
|
165
|
+
throw new AimakeallApiError(describeApiFailure(response.status, payload), {
|
|
166
|
+
errorCode: String(payload?.error || ""),
|
|
167
|
+
retryAfterSeconds: Number(payload?.retryAfterSeconds) || 0,
|
|
168
|
+
status: response.status,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const events = [];
|
|
172
|
+
let result = null;
|
|
173
|
+
let buffer = "";
|
|
174
|
+
const decoder = new TextDecoder();
|
|
175
|
+
try {
|
|
176
|
+
for await (const chunk of response.body) {
|
|
177
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
178
|
+
let frameEnd;
|
|
179
|
+
while ((frameEnd = buffer.indexOf("\n\n")) >= 0) {
|
|
180
|
+
const frame = buffer.slice(0, frameEnd);
|
|
181
|
+
buffer = buffer.slice(frameEnd + 2);
|
|
182
|
+
for (const line of frame.split("\n")) {
|
|
183
|
+
if (!line.startsWith("data:")) continue;
|
|
184
|
+
let payload = null;
|
|
185
|
+
try {
|
|
186
|
+
payload = JSON.parse(line.slice(5).trim());
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!payload || typeof payload !== "object") continue;
|
|
191
|
+
if (payload.type === "error") {
|
|
192
|
+
throw new AimakeallApiError(String(payload.message || payload.error || "스트리밍 생성 중 오류가 발생했습니다."), {
|
|
193
|
+
errorCode: "SSE_ERROR",
|
|
194
|
+
status: 200,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
events.push(payload);
|
|
198
|
+
if (payload.type === "result") result = payload.result;
|
|
199
|
+
onEvent?.(payload);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error instanceof AimakeallApiError) throw error;
|
|
205
|
+
// 스트림 중단(idle timeout·네트워크) — 서버에서는 생성이 계속 완료·과금됐을 수 있다.
|
|
206
|
+
throw new AimakeallApiError(
|
|
207
|
+
"스트리밍 수신이 중단되었습니다. 서버에서는 생성이 계속 완료되어 과금됐을 수 있으니, 무작정 재시도하지 말고 계정 사용량/결과를 먼저 확인하세요.",
|
|
208
|
+
{ errorCode: "SSE_INTERRUPTED", status: 0 },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return { events, result };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { request, requestBinary, requestSse, requestStreamUpload };
|
|
143
215
|
}
|
package/lib/cloud-tools.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
encodedDataUrlBytes,
|
|
14
14
|
fileToDataUrl,
|
|
15
15
|
fileToImagePayload,
|
|
16
|
+
guessMimeType,
|
|
16
17
|
saveMediaBuffer,
|
|
17
18
|
savePayloadHandle,
|
|
18
19
|
SERVER_JSON_BODY_LIMIT_BYTES,
|
|
@@ -129,7 +130,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
129
130
|
{
|
|
130
131
|
topic: z.string().optional().describe("영상 주제"),
|
|
131
132
|
copy: z.string().optional().describe("핵심 카피/대사"),
|
|
132
|
-
categoryId: z.enum(["community-shorts", "viral-shorts"]).optional().describe("기본 community-shorts"),
|
|
133
|
+
categoryId: z.enum(["community-shorts", "viral-shorts", "insight"]).optional().describe("기본 community-shorts. insight=명언(통찰) 쇼츠"),
|
|
133
134
|
targetCustomer: z.string().optional(),
|
|
134
135
|
tone: z.string().optional().describe("기본 '자동 추천'"),
|
|
135
136
|
sceneCount: z.number().int().min(1).max(10).optional().describe("기본 3"),
|
|
@@ -496,7 +497,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
496
497
|
endSec: z.number(),
|
|
497
498
|
startSec: z.number(),
|
|
498
499
|
text: z.string(),
|
|
499
|
-
})).optional().describe("자막 타이밍 (쇼츠)"),
|
|
500
|
+
})).optional().describe("자막 타이밍 (쇼츠 하단자막) — tts_narration_with_captions의 subtitleLines를 그대로 사용"),
|
|
500
501
|
transitionMode: z.enum(["fade", "none"]).optional().describe("기본 fade"),
|
|
501
502
|
},
|
|
502
503
|
wrapCloudHandler(config, async ({ sceneVideos, featureKey = "commerceVideo", projectTitle = "aimakeall-mcp", aspectRatio = "9:16", ttsAudioPath = "", bgmAudioPath = "", audioDurationSec = 0, bgmVolume = null, muteVideoAudio = true, titleText = "", subtitleLines = [], transitionMode = "fade" }) => {
|
|
@@ -641,4 +642,642 @@ export function registerCloudTools(server, config, api) {
|
|
|
641
642
|
});
|
|
642
643
|
}),
|
|
643
644
|
);
|
|
645
|
+
|
|
646
|
+
// ── 대본(Script Studio) — 스타일별 지침 자동 적용 ────────────────────────────
|
|
647
|
+
server.tool(
|
|
648
|
+
"write_script",
|
|
649
|
+
"대본을 생성합니다 (스크립트 스튜디오). 스타일별 전용 지침이 자동 적용됩니다: standard(스탠다드)/senior(시니어)/insight(명언·통찰)/webnovel(웹소설)/christian(기독교)/community-shorts/viral-shorts. 스탠다드·시니어·명언은 실시간 리서치가 함께 수행됩니다. 부동산·상권 분석 대본은 전용 도구(write_real_estate_report/write_market_analysis)를 쓰세요.",
|
|
650
|
+
{
|
|
651
|
+
title: z.string().describe("영상 제목/주제"),
|
|
652
|
+
storyText: z.string().optional().describe("핵심 줄거리·내용 브리프"),
|
|
653
|
+
styleId: z.enum(["standard", "senior", "insight", "webnovel", "christian", "community-shorts", "viral-shorts"]).optional().describe("기본 standard. senior=시니어(쉽고 친절), insight=명언(통찰)"),
|
|
654
|
+
model: z.enum(["gemini", "opus", "sonnet"]).optional().describe("기본 gemini(가성비). opus/sonnet=Claude"),
|
|
655
|
+
targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
|
|
656
|
+
targetLanguageLabel: z.string().optional().describe("출력 언어 라벨, 기본 '한국 (한국어)'"),
|
|
657
|
+
contentFormat: z.enum(["longform", "shortform"]).optional().describe("기본 longform (standard/senior/insight는 항상 longform)"),
|
|
658
|
+
webnovelSubtypeId: z.enum(["modern-webnovel", "regression-fantasy", "romance-fantasy", "martial-arts", "light-novel", "hunter-fantasy", "revenge-soda", "healing-daily", "relationship-drama", "literary-serial"]).optional().describe("styleId=webnovel일 때 장르"),
|
|
659
|
+
christianContentType: z.enum(["youtube-narration", "prayer", "devotional", "comfort-message"]).optional().describe("styleId=christian일 때 유형"),
|
|
660
|
+
insightFigureNames: z.string().optional().describe("styleId=insight일 때 인물명(쉼표 구분)"),
|
|
661
|
+
userOpinion: z.string().optional().describe("본문에 자연스럽게 녹일 작성자 의견"),
|
|
662
|
+
},
|
|
663
|
+
wrapCloudHandler(config, async ({
|
|
664
|
+
title,
|
|
665
|
+
storyText = "",
|
|
666
|
+
styleId = "standard",
|
|
667
|
+
model = "gemini",
|
|
668
|
+
targetLength = 5000,
|
|
669
|
+
targetLanguageLabel = "한국 (한국어)",
|
|
670
|
+
contentFormat = "longform",
|
|
671
|
+
webnovelSubtypeId = "modern-webnovel",
|
|
672
|
+
christianContentType = "youtube-narration",
|
|
673
|
+
insightFigureNames = "",
|
|
674
|
+
userOpinion = "",
|
|
675
|
+
}) => {
|
|
676
|
+
const SCRIPT_MODEL_MAP = { gemini: "gemini-3.5-flash", opus: "claude-opus-4-8", sonnet: "claude-sonnet-5" };
|
|
677
|
+
const payload = await api.request("/api/tracker/ai/script", {
|
|
678
|
+
body: {
|
|
679
|
+
apiModel: SCRIPT_MODEL_MAP[model] || SCRIPT_MODEL_MAP.gemini,
|
|
680
|
+
channelStyleProfile: null,
|
|
681
|
+
channelStyleReferenceText: "",
|
|
682
|
+
christianContentType,
|
|
683
|
+
contentFormat,
|
|
684
|
+
generationIntent: "new",
|
|
685
|
+
insightFigureMode: insightFigureNames.trim() ? "blend" : "auto",
|
|
686
|
+
insightFigureNames,
|
|
687
|
+
insightToneMode: "warm",
|
|
688
|
+
modelKey: model,
|
|
689
|
+
personas: [],
|
|
690
|
+
previousScript: "",
|
|
691
|
+
selectedStyleId: styleId,
|
|
692
|
+
storyMemory: null,
|
|
693
|
+
storyText,
|
|
694
|
+
targetLanguageLabel,
|
|
695
|
+
targetLength,
|
|
696
|
+
title,
|
|
697
|
+
userOpinion,
|
|
698
|
+
webnovelSubtypeId,
|
|
699
|
+
},
|
|
700
|
+
method: "POST",
|
|
701
|
+
timeoutMs: 240_000,
|
|
702
|
+
});
|
|
703
|
+
const text = String(payload?.text || "").trim();
|
|
704
|
+
if (!text) return textResult("대본 생성 결과가 비어 있습니다. 제목/브리프를 보강해 다시 시도하세요.", { isError: true });
|
|
705
|
+
return jsonResult({ model: payload?.model, styleId, text });
|
|
706
|
+
}),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
server.tool(
|
|
710
|
+
"write_real_estate_report",
|
|
711
|
+
"부동산(아파트/빌라/지역) 종합분석 나레이션 대본을 생성합니다 (Gemini 검색 리서치 기반). primaryTarget/region/title/storyText 중 하나 이상은 필수입니다.",
|
|
712
|
+
{
|
|
713
|
+
analysisType: z.enum(["apartment-report", "region-recommend", "comparison", "price-check", "outlook", "policy", "villa", "fact-check"]).optional().describe("기본 apartment-report"),
|
|
714
|
+
primaryTarget: z.string().optional().describe("주요 대상 단지/지역"),
|
|
715
|
+
secondaryTarget: z.string().optional().describe("비교 대상 (analysisType=comparison이면 필수)"),
|
|
716
|
+
region: z.string().optional().describe("지역/생활권"),
|
|
717
|
+
title: z.string().optional().describe("브리프 제목"),
|
|
718
|
+
storyText: z.string().optional().describe("핵심 브리프"),
|
|
719
|
+
purpose: z.enum(["investment", "owner-occupancy", "move-up", "verification"]).optional().describe("기본 investment"),
|
|
720
|
+
budget: z.string().optional().describe("예산/가격대"),
|
|
721
|
+
notes: z.string().optional(),
|
|
722
|
+
targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
|
|
723
|
+
targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
|
|
724
|
+
},
|
|
725
|
+
wrapCloudHandler(config, async ({ analysisType = "apartment-report", primaryTarget = "", secondaryTarget = "", region = "", title = "", storyText = "", purpose = "investment", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
|
|
726
|
+
if (![primaryTarget, region, title, storyText].some((value) => String(value).trim())) {
|
|
727
|
+
return textResult("primaryTarget/region/title/storyText 중 하나는 입력해야 합니다.", { isError: true });
|
|
728
|
+
}
|
|
729
|
+
if (analysisType === "comparison" && !String(secondaryTarget).trim()) {
|
|
730
|
+
return textResult("analysisType=comparison에는 secondaryTarget(비교 대상)이 필수입니다.", { isError: true });
|
|
731
|
+
}
|
|
732
|
+
const payload = await api.request("/api/tracker/ai/real-estate-report", {
|
|
733
|
+
body: {
|
|
734
|
+
analysisType,
|
|
735
|
+
apiModel: "gemini-3.5-flash",
|
|
736
|
+
budget,
|
|
737
|
+
channelStyleProfile: null,
|
|
738
|
+
channelStyleReferenceText: "",
|
|
739
|
+
modelKey: "gemini",
|
|
740
|
+
notes,
|
|
741
|
+
personas: [],
|
|
742
|
+
primaryTarget,
|
|
743
|
+
purpose,
|
|
744
|
+
region,
|
|
745
|
+
secondaryTarget,
|
|
746
|
+
storyText,
|
|
747
|
+
targetLanguageLabel,
|
|
748
|
+
targetLength,
|
|
749
|
+
title,
|
|
750
|
+
},
|
|
751
|
+
method: "POST",
|
|
752
|
+
timeoutMs: 240_000,
|
|
753
|
+
});
|
|
754
|
+
const text = String(payload?.text || "").trim();
|
|
755
|
+
if (!text) return textResult("부동산 분석 대본 결과가 비어 있습니다.", { isError: true });
|
|
756
|
+
return jsonResult({ analysisType, model: payload?.model, researchQueries: payload?.research?.performedQueryCount, text });
|
|
757
|
+
}),
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
server.tool(
|
|
761
|
+
"write_market_analysis",
|
|
762
|
+
"상권 분석 나레이션 대본을 생성합니다 (Gemini 검색 리서치 기반). primaryTarget/businessType/title/storyText 중 하나 이상은 필수입니다.",
|
|
763
|
+
{
|
|
764
|
+
analysisType: z.string().optional().describe("기본 commercial-district-report"),
|
|
765
|
+
primaryTarget: z.string().optional().describe("주요 상권/지역"),
|
|
766
|
+
secondaryTarget: z.string().optional().describe("비교 상권 (comparison이면 필수)"),
|
|
767
|
+
businessType: z.string().optional().describe("업종/업태 (예: 카페, 편의점)"),
|
|
768
|
+
title: z.string().optional().describe("브리프 제목"),
|
|
769
|
+
storyText: z.string().optional().describe("핵심 브리프"),
|
|
770
|
+
purpose: z.enum(["startup", "investment", "site-selection", "verification"]).optional().describe("기본 startup"),
|
|
771
|
+
budget: z.string().optional().describe("예산/운영 여력"),
|
|
772
|
+
notes: z.string().optional(),
|
|
773
|
+
targetLength: z.number().int().min(500).max(20000).optional().describe("목표 글자수, 기본 5000"),
|
|
774
|
+
targetLanguageLabel: z.string().optional().describe("기본 '한국 (한국어)'"),
|
|
775
|
+
},
|
|
776
|
+
wrapCloudHandler(config, async ({ analysisType = "commercial-district-report", primaryTarget = "", secondaryTarget = "", businessType = "", title = "", storyText = "", purpose = "startup", budget = "", notes = "", targetLength = 5000, targetLanguageLabel = "한국 (한국어)" }) => {
|
|
777
|
+
if (![primaryTarget, businessType, title, storyText].some((value) => String(value).trim())) {
|
|
778
|
+
return textResult("primaryTarget/businessType/title/storyText 중 하나는 입력해야 합니다.", { isError: true });
|
|
779
|
+
}
|
|
780
|
+
if (analysisType === "comparison" && !String(secondaryTarget).trim()) {
|
|
781
|
+
return textResult("analysisType=comparison에는 secondaryTarget(비교 상권)이 필수입니다.", { isError: true });
|
|
782
|
+
}
|
|
783
|
+
const payload = await api.request("/api/tracker/ai/market-analysis", {
|
|
784
|
+
body: {
|
|
785
|
+
analysisType,
|
|
786
|
+
apiModel: "gemini-3.5-flash",
|
|
787
|
+
budget,
|
|
788
|
+
businessType,
|
|
789
|
+
channelStyleProfile: null,
|
|
790
|
+
channelStyleReferenceText: "",
|
|
791
|
+
modelKey: "gemini",
|
|
792
|
+
notes,
|
|
793
|
+
personas: [],
|
|
794
|
+
primaryTarget,
|
|
795
|
+
purpose,
|
|
796
|
+
secondaryTarget,
|
|
797
|
+
storyText,
|
|
798
|
+
targetLanguageLabel,
|
|
799
|
+
targetLength,
|
|
800
|
+
title,
|
|
801
|
+
},
|
|
802
|
+
method: "POST",
|
|
803
|
+
timeoutMs: 240_000,
|
|
804
|
+
});
|
|
805
|
+
const text = String(payload?.text || "").trim();
|
|
806
|
+
if (!text) return textResult("상권 분석 대본 결과가 비어 있습니다.", { isError: true });
|
|
807
|
+
return jsonResult({ analysisType, model: payload?.model, researchQueries: payload?.research?.performedQueryCount, text });
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
// ── 뮤직비디오 기획 ──────────────────────────────────────────────────────────
|
|
812
|
+
server.tool(
|
|
813
|
+
"plan_music_video",
|
|
814
|
+
"뮤직비디오 씬 플랜을 생성합니다. suno_music_download로 받은 곡의 가사·길이(durationSec)를 입력하세요. 결과 scenes의 imagePrompt는 generate_scene_image로 이어집니다.",
|
|
815
|
+
{
|
|
816
|
+
durationSec: z.number().min(10).describe("곡 길이(초) — suno_music_download 결과의 durationSec"),
|
|
817
|
+
lyrics: z.string().optional().describe("가사 전체 (instrumental이면 생략 가능)"),
|
|
818
|
+
conceptBrief: z.string().optional().describe("영상 컨셉 브리프"),
|
|
819
|
+
instrumental: z.boolean().optional().describe("연주곡 여부, 기본 false"),
|
|
820
|
+
sceneSeconds: z.number().int().min(3).max(8).optional().describe("씬 길이(초), 기본 5"),
|
|
821
|
+
splitMode: z.enum(["fixed", "auto"]).optional().describe("씬 분할, 기본 fixed"),
|
|
822
|
+
videoStylePreset: z.string().optional().describe("기본 seedance-music-video"),
|
|
823
|
+
aspectRatio: z.string().optional().describe("기본 9:16"),
|
|
824
|
+
},
|
|
825
|
+
wrapCloudHandler(config, async ({ durationSec, lyrics = "", conceptBrief = "", instrumental = false, sceneSeconds = 5, splitMode = "fixed", videoStylePreset = "seedance-music-video", aspectRatio = "9:16" }) => {
|
|
826
|
+
if (!instrumental && !String(lyrics).trim()) {
|
|
827
|
+
return textResult("lyrics를 입력하거나 instrumental=true로 지정하세요.", { isError: true });
|
|
828
|
+
}
|
|
829
|
+
const payload = await api.request("/api/tracker/music-video/plan", {
|
|
830
|
+
body: {
|
|
831
|
+
alignedLines: [],
|
|
832
|
+
aspectRatio,
|
|
833
|
+
conceptBrief,
|
|
834
|
+
durationSec,
|
|
835
|
+
instrumental,
|
|
836
|
+
lyrics,
|
|
837
|
+
sceneSeconds,
|
|
838
|
+
selectedCharacters: [],
|
|
839
|
+
songMeta: {},
|
|
840
|
+
splitMode,
|
|
841
|
+
videoStylePreset,
|
|
842
|
+
},
|
|
843
|
+
method: "POST",
|
|
844
|
+
timeoutMs: 240_000,
|
|
845
|
+
});
|
|
846
|
+
// usage/원장 이벤트 등 컨텍스트 낭비 필드는 제거하고 기획 본문만 돌려준다.
|
|
847
|
+
const { accountCostEvents, providerUsage, usage, ...plan } = payload && typeof payload === "object" ? payload : {};
|
|
848
|
+
return jsonResult(plan);
|
|
849
|
+
}),
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
// ── 짜집기 편집점 분석 (티키타카 편집점 지침) ────────────────────────────────
|
|
853
|
+
server.tool(
|
|
854
|
+
"analyze_edit_points",
|
|
855
|
+
"짜집기(리메이크)용 편집점 분석 — 티키타카 편집점 지침으로 원본 YouTube 영상의 장면 카탈로그(sceneCatalog)를 만듭니다. 전사는 자동 확보합니다. 컷 정확도를 높이려면 detect_shots(로컬 컴패니언)로 만든 shotsFile 경로를 videoId별로 넘기세요.",
|
|
856
|
+
{
|
|
857
|
+
videos: z.array(z.object({
|
|
858
|
+
id: z.string().describe("YouTube video id"),
|
|
859
|
+
title: z.string().optional(),
|
|
860
|
+
channel: z.string().optional(),
|
|
861
|
+
durationSeconds: z.number().optional(),
|
|
862
|
+
})).min(1).max(5).describe("분석할 원본 영상 (최대 5편)"),
|
|
863
|
+
shotsFiles: z.record(z.string()).optional().describe("videoId → detect_shots가 저장한 shots JSON 파일 경로"),
|
|
864
|
+
},
|
|
865
|
+
wrapCloudHandler(config, async ({ videos, shotsFiles = {} }) => {
|
|
866
|
+
// 1) 실제 오디오 전사 확보 — 편집점 분류([N]/[S]/[A])의 핵심 신호.
|
|
867
|
+
// 서버가 오디오 다운로드+Gemini 전사를 수행하므로 영상당 수 분 걸릴 수 있다.
|
|
868
|
+
const transcriptPayload = await api.request("/api/tracker/youtube/transcripts", {
|
|
869
|
+
body: { videos: videos.map((video) => ({ channel: video.channel || "", id: video.id, title: video.title || "" })) },
|
|
870
|
+
method: "POST",
|
|
871
|
+
timeoutMs: 600_000,
|
|
872
|
+
});
|
|
873
|
+
const transcriptMap = new Map(
|
|
874
|
+
(transcriptPayload?.items || []).map((item) => [item.videoId, Array.isArray(item.segments) ? item.segments : []]),
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
// 2) 컴패니언 샷 파일 로드 (imageBase64 포함) — 있으면 서버가 실제 컷 기준으로 분석.
|
|
878
|
+
const preDetectedShots = {};
|
|
879
|
+
for (const [videoId, filePath] of Object.entries(shotsFiles)) {
|
|
880
|
+
const parsed = JSON.parse(readFileSync(String(filePath), "utf8"));
|
|
881
|
+
const shots = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.shots) ? parsed.shots : [];
|
|
882
|
+
if (shots.length) preDetectedShots[videoId] = shots;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const body = {
|
|
886
|
+
preDetectedShots: Object.keys(preDetectedShots).length ? preDetectedShots : null,
|
|
887
|
+
videos: videos.map((video) => ({
|
|
888
|
+
channel: video.channel || "",
|
|
889
|
+
durationSeconds: Number(video.durationSeconds) || 0,
|
|
890
|
+
frameSamples: [],
|
|
891
|
+
id: video.id,
|
|
892
|
+
title: video.title || "",
|
|
893
|
+
transcriptSegments: transcriptMap.get(video.id) || [],
|
|
894
|
+
})),
|
|
895
|
+
};
|
|
896
|
+
if (Buffer.byteLength(JSON.stringify(body), "utf8") > BODY_BUDGET_BYTES) {
|
|
897
|
+
return textResult("샷 이미지 총량이 서버 상한을 초과합니다. 영상 수 또는 detect_shots의 maxShots를 줄이세요.", { isError: true });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const payload = await api.request("/api/tracker/gemini/video-analysis", { body, method: "POST", timeoutMs: 240_000 });
|
|
901
|
+
const hadShots = Object.keys(preDetectedShots).length > 0;
|
|
902
|
+
// 이미지/전사 원문은 빼고 편집점 정보만 컨텍스트로 반환.
|
|
903
|
+
// sceneCatalog 필드명은 서버 sanitizeSceneCatalogItem 과 정확히 일치해야 한다.
|
|
904
|
+
return jsonResult({
|
|
905
|
+
summaryBody: payload?.summaryBody,
|
|
906
|
+
summaryHeadline: payload?.summaryHeadline,
|
|
907
|
+
videos: (payload?.videos || []).map((video) => ({
|
|
908
|
+
angle: video?.angle,
|
|
909
|
+
hook: video?.hook,
|
|
910
|
+
id: video?.id,
|
|
911
|
+
keywords: video?.keywords,
|
|
912
|
+
recommendedPreset: video?.recommendedPreset,
|
|
913
|
+
sceneCatalog: (video?.sceneCatalog || []).map((scene) => ({
|
|
914
|
+
actionTags: scene?.actionTags,
|
|
915
|
+
ambienceScore: scene?.ambienceScore,
|
|
916
|
+
dialogueVisualScore: scene?.dialogueVisualScore,
|
|
917
|
+
emotionTags: scene?.emotionTags,
|
|
918
|
+
endMs: scene?.endMs,
|
|
919
|
+
moodTags: scene?.moodTags,
|
|
920
|
+
narrationFit: scene?.narrationFit,
|
|
921
|
+
narrationFitScore: scene?.narrationFitScore,
|
|
922
|
+
shotId: scene?.shotId,
|
|
923
|
+
shortLabel: scene?.shortLabel,
|
|
924
|
+
startMs: scene?.startMs,
|
|
925
|
+
timecodeLabel: scene?.timecodeLabel,
|
|
926
|
+
visualSummary: scene?.visualSummary,
|
|
927
|
+
})),
|
|
928
|
+
})),
|
|
929
|
+
...(hadShots ? {} : {
|
|
930
|
+
warning: "shotsFiles 없이 분석했습니다 — 장면 타임스탬프는 추정치입니다. 정확한 컷 기준 편집점이 필요하면 detect_shots(로컬 컴패니언)로 샷을 감지해 shotsFiles로 넘기세요.",
|
|
931
|
+
}),
|
|
932
|
+
});
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
// ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
|
|
937
|
+
server.tool(
|
|
938
|
+
"tts_narration_with_captions",
|
|
939
|
+
"ElevenLabs TTS를 char-level 타이밍과 함께 합성해 mp3 파일 + 시간 동기 자막 라인(subtitleLines)을 만듭니다. 쇼츠 하단자막이 필요하면 tts_narration 대신 이걸 쓰고, 결과 subtitleLines·durationSec·filePath를 stitch_timeline에 그대로 넘기세요 (웹 쇼츠 스튜디오와 동일한 정렬 방식).",
|
|
940
|
+
{
|
|
941
|
+
text: z.string().describe("내레이션 텍스트"),
|
|
942
|
+
voice: z.string().optional().describe("보이스 이름 또는 ID (생략 시 기본 보이스)"),
|
|
943
|
+
speed: z.number().optional().describe("기본 1 (0.7~1.2)"),
|
|
944
|
+
},
|
|
945
|
+
wrapCloudHandler(config, async ({ text, voice = "", speed = 1 }) => {
|
|
946
|
+
const payload = await api.request("/api/tracker/tts/elevenlabs-with-timestamps", {
|
|
947
|
+
body: {
|
|
948
|
+
costEventId: createUsageEventId("mcp-tts-elevenlabs-ts"),
|
|
949
|
+
languageCode: "ko",
|
|
950
|
+
speed,
|
|
951
|
+
text,
|
|
952
|
+
voice,
|
|
953
|
+
},
|
|
954
|
+
method: "POST",
|
|
955
|
+
timeoutMs: 180_000,
|
|
956
|
+
});
|
|
957
|
+
const audioBase64 = String(payload?.audioBase64 || "");
|
|
958
|
+
if (!audioBase64) {
|
|
959
|
+
return textResult("TTS 합성 결과가 비어 있습니다. ElevenLabs API 키 연결 여부를 확인하세요.", { isError: true });
|
|
960
|
+
}
|
|
961
|
+
const saved = saveMediaBuffer(config.stateDir, "narration-captions.mp3", Buffer.from(audioBase64, "base64"), { contentType: "audio/mpeg" });
|
|
962
|
+
const subtitleLines = (Array.isArray(payload?.alignedLines) ? payload.alignedLines : [])
|
|
963
|
+
.map((line) => ({
|
|
964
|
+
endSec: Number(line?.endSec) || 0,
|
|
965
|
+
startSec: Number(line?.startSec) || 0,
|
|
966
|
+
text: String(line?.text || "").trim(),
|
|
967
|
+
}))
|
|
968
|
+
.filter((line) => line.text && line.endSec > line.startSec);
|
|
969
|
+
const charEnds = payload?.alignment?.character_end_times_seconds;
|
|
970
|
+
const durationSec = Array.isArray(charEnds) && charEnds.length
|
|
971
|
+
? Math.round(Number(charEnds[charEnds.length - 1]) * 10) / 10
|
|
972
|
+
: audioDurationSecFromFile(saved.filePath);
|
|
973
|
+
return jsonResult({
|
|
974
|
+
durationSec,
|
|
975
|
+
filePath: saved.filePath,
|
|
976
|
+
lineCount: subtitleLines.length,
|
|
977
|
+
subtitleLines,
|
|
978
|
+
});
|
|
979
|
+
}),
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
// ── TTS 보이스 카탈로그 ──────────────────────────────────────────────────────
|
|
983
|
+
server.tool(
|
|
984
|
+
"tts_list_voices",
|
|
985
|
+
"TTS 보이스 목록을 조회합니다. tts_narration의 voice에 넣을 이름을 고를 때 사용하세요. (voice를 생략하면 서버가 기본 보이스로 합성합니다)",
|
|
986
|
+
{
|
|
987
|
+
provider: z.enum(["typecast", "elevenlabs"]).optional().describe("기본 typecast"),
|
|
988
|
+
},
|
|
989
|
+
wrapCloudHandler(config, async ({ provider = "typecast" }) => {
|
|
990
|
+
const payload = await api.request(`/api/tracker/tts/${provider}/voices`, { method: "GET", timeoutMs: 30_000 });
|
|
991
|
+
// ElevenLabs 는 gender/age 가 labels 객체 안에 있다 — 두 공급자 형태 모두 지원.
|
|
992
|
+
const voices = (Array.isArray(payload?.voices) ? payload.voices : []).slice(0, 80).map((voice) => ({
|
|
993
|
+
age: voice?.age || voice?.labels?.age,
|
|
994
|
+
description: voice?.description || undefined,
|
|
995
|
+
gender: voice?.gender || voice?.labels?.gender,
|
|
996
|
+
name: voice?.voiceName || voice?.name,
|
|
997
|
+
nameKo: voice?.voiceNameKo,
|
|
998
|
+
voiceId: voice?.voiceId,
|
|
999
|
+
}));
|
|
1000
|
+
return jsonResult({ count: voices.length, provider, voices });
|
|
1001
|
+
}),
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
// ── 인플루언서(소울) 스튜디오 ────────────────────────────────────────────────
|
|
1005
|
+
server.tool(
|
|
1006
|
+
"create_influencer_character",
|
|
1007
|
+
"참조 사진으로 인플루언서(소울) 캐릭터를 학습 등록합니다. 학습 비용 $2.50(1회성) — 실패 시 자동 재시도 금지(중복 과금). 등록 후 influencer_character_status로 completed까지 10초 간격 폴링하세요 (수 분 소요).",
|
|
1008
|
+
{
|
|
1009
|
+
name: z.string().min(1).describe("캐릭터 이름"),
|
|
1010
|
+
photoPaths: z.array(z.string()).min(5).max(18).describe("동일 인물 사진 로컬 경로 5~18장 (jpg/png/webp)"),
|
|
1011
|
+
},
|
|
1012
|
+
wrapCloudHandler(config, async ({ name, photoPaths }) => {
|
|
1013
|
+
for (const filePath of photoPaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "참조 사진" });
|
|
1014
|
+
const publicUrls = [];
|
|
1015
|
+
// 업로드 URL 발급은 사진 수만큼 쓰기 슬롯(20/분)을 쓰므로, 분당 한도에 걸리면
|
|
1016
|
+
// retryAfterSeconds 만큼 대기 후 이어서 진행한다(발급된 URL 은 보존).
|
|
1017
|
+
const issueUploadUrl = async (mime) => {
|
|
1018
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1019
|
+
try {
|
|
1020
|
+
return await api.request("/api/tracker/higgsfield/files/generate-upload-url", {
|
|
1021
|
+
body: { content_type: mime },
|
|
1022
|
+
method: "POST",
|
|
1023
|
+
timeoutMs: 30_000,
|
|
1024
|
+
});
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
if (error instanceof AimakeallApiError && error.errorCode === "MACHINE_RATE_LIMITED" && attempt < 3) {
|
|
1027
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(1, error.retryAfterSeconds || 10) * 1000));
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
// presigned PUT(직접) — 컴패니언 불필요.
|
|
1035
|
+
for (const filePath of photoPaths) {
|
|
1036
|
+
const mime = guessMimeType(filePath, "image/jpeg");
|
|
1037
|
+
const issued = await issueUploadUrl(mime);
|
|
1038
|
+
const uploadUrl = String(issued?.upload_url || "");
|
|
1039
|
+
const publicUrl = String(issued?.public_url || "");
|
|
1040
|
+
if (!uploadUrl || !publicUrl) {
|
|
1041
|
+
return textResult("업로드 URL 발급에 실패했습니다. Higgsfield API 키 연결을 확인하세요.", { isError: true });
|
|
1042
|
+
}
|
|
1043
|
+
const put = await fetch(uploadUrl, {
|
|
1044
|
+
body: readFileSync(filePath),
|
|
1045
|
+
headers: { "Content-Type": mime },
|
|
1046
|
+
method: "PUT",
|
|
1047
|
+
signal: AbortSignal.timeout(120_000),
|
|
1048
|
+
});
|
|
1049
|
+
if (!put.ok) {
|
|
1050
|
+
return textResult(`사진 업로드 실패 (HTTP ${put.status}): ${path.basename(filePath)}`, { isError: true });
|
|
1051
|
+
}
|
|
1052
|
+
publicUrls.push(publicUrl);
|
|
1053
|
+
}
|
|
1054
|
+
let payload;
|
|
1055
|
+
try {
|
|
1056
|
+
// 서버의 업스트림 창(90s)보다 길게 잡아, 클라이언트가 먼저 끊어 "성공했는데
|
|
1057
|
+
// 실패로 오인 → 재시도 → 중복 과금($2.50)" 되는 상황을 방지한다.
|
|
1058
|
+
payload = await api.request("/api/tracker/higgsfield/v1/custom-references", {
|
|
1059
|
+
body: { input_images: publicUrls.map((url) => ({ image_url: url, type: "image_url" })), name },
|
|
1060
|
+
method: "POST",
|
|
1061
|
+
timeoutMs: 120_000,
|
|
1062
|
+
});
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
const reason = String(error?.message || error);
|
|
1065
|
+
return textResult(
|
|
1066
|
+
`캐릭터 학습 등록 요청이 실패했습니다: ${reason}\n재시도 전에 반드시 list_influencer_characters로 캐릭터가 이미 생성됐는지 확인하세요 — 생성돼 있다면 재시도는 중복 과금($2.50)입니다.`,
|
|
1067
|
+
{ isError: true },
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
const referenceId = String(payload?.id || payload?.reference_id || "");
|
|
1071
|
+
if (!referenceId) return textResult("캐릭터 학습 등록 응답에 ID가 없습니다. list_influencer_characters로 생성 여부를 확인하세요.", { isError: true });
|
|
1072
|
+
return jsonResult({
|
|
1073
|
+
costUsd: payload?.costUsd,
|
|
1074
|
+
next: "influencer_character_status를 10초 간격으로 폴링해 completed를 기다리세요. 이 호출을 재시도하면 중복 과금됩니다.",
|
|
1075
|
+
photoCount: publicUrls.length,
|
|
1076
|
+
referenceId,
|
|
1077
|
+
status: payload?.status,
|
|
1078
|
+
});
|
|
1079
|
+
}),
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
server.tool(
|
|
1083
|
+
"influencer_character_status",
|
|
1084
|
+
"소울 캐릭터 학습 상태를 조회합니다 (terminal: completed | failed).",
|
|
1085
|
+
{ referenceId: z.string().min(1) },
|
|
1086
|
+
wrapCloudHandler(config, async ({ referenceId }) => {
|
|
1087
|
+
const payload = await api.request(`/api/tracker/higgsfield/v1/custom-references/${encodeURIComponent(referenceId)}`, {
|
|
1088
|
+
method: "GET",
|
|
1089
|
+
timeoutMs: 30_000,
|
|
1090
|
+
});
|
|
1091
|
+
return jsonResult({
|
|
1092
|
+
name: payload?.name,
|
|
1093
|
+
referenceId,
|
|
1094
|
+
status: payload?.status,
|
|
1095
|
+
thumbnailUrl: payload?.thumbnail_url,
|
|
1096
|
+
});
|
|
1097
|
+
}),
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
server.tool(
|
|
1101
|
+
"list_influencer_characters",
|
|
1102
|
+
"등록된 소울 캐릭터 목록을 조회합니다. (machine 토큰은 서버 경유로 만든 캐릭터만 상세 조회·생성에 사용 가능)",
|
|
1103
|
+
{ page: z.number().int().min(1).optional().describe("기본 1") },
|
|
1104
|
+
wrapCloudHandler(config, async ({ page = 1 }) => {
|
|
1105
|
+
const payload = await api.request(`/api/tracker/higgsfield/v1/custom-references/list?page=${page}&page_size=50`, {
|
|
1106
|
+
method: "GET",
|
|
1107
|
+
timeoutMs: 30_000,
|
|
1108
|
+
});
|
|
1109
|
+
return jsonResult({
|
|
1110
|
+
characters: (Array.isArray(payload?.items) ? payload.items : []).map((item) => ({
|
|
1111
|
+
id: item?.id,
|
|
1112
|
+
name: item?.name,
|
|
1113
|
+
status: item?.status,
|
|
1114
|
+
})),
|
|
1115
|
+
page,
|
|
1116
|
+
totalPages: payload?.total_pages,
|
|
1117
|
+
});
|
|
1118
|
+
}),
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
server.tool(
|
|
1122
|
+
"suggest_influencer_prompts",
|
|
1123
|
+
"소울 이미지용 연출·프롬프트 10종을 추천받습니다. 반환 prompt는 부정규칙까지 완성형이므로 generate_influencer_image에 그대로 쓰세요.",
|
|
1124
|
+
{
|
|
1125
|
+
gender: z.enum(["여자", "남자"]),
|
|
1126
|
+
ageRange: z.enum(["10대", "20대", "30대", "40대", "50대", "60대"]),
|
|
1127
|
+
keyword: z.string().min(1).describe("장면 키워드 (예: 카페에서 책 읽는 모습)"),
|
|
1128
|
+
},
|
|
1129
|
+
wrapCloudHandler(config, async ({ gender, ageRange, keyword }) => {
|
|
1130
|
+
const { events } = await api.requestSse("/api/tracker/gemini/soul-prompts", {
|
|
1131
|
+
body: { ageRange, gender, imageModel: "soul", keyword },
|
|
1132
|
+
timeoutMs: 180_000,
|
|
1133
|
+
});
|
|
1134
|
+
const items = events
|
|
1135
|
+
.filter((event) => event?.type === "item" && event.item)
|
|
1136
|
+
.map((event) => ({ directing: event.item.directing, id: event.item.id, prompt: event.item.prompt }));
|
|
1137
|
+
if (!items.length) return textResult("프롬프트 추천 결과가 비어 있습니다.", { isError: true });
|
|
1138
|
+
return jsonResult({ count: items.length, items });
|
|
1139
|
+
}),
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
server.tool(
|
|
1143
|
+
"generate_influencer_image",
|
|
1144
|
+
"학습 완료(completed)된 소울 캐릭터로 인플루언서 이미지를 생성합니다 (720p $0.094/장, 1080p $0.19/장). 반환 requestId를 influencer_image_status로 폴링하세요 (~30초).",
|
|
1145
|
+
{
|
|
1146
|
+
prompt: z.string().min(1).describe("영문 프롬프트 (suggest_influencer_prompts 결과 그대로 권장)"),
|
|
1147
|
+
referenceId: z.string().min(1).describe("completed 상태의 캐릭터 referenceId"),
|
|
1148
|
+
aspectRatio: z.enum(["9:16", "16:9", "4:3", "3:4", "1:1", "2:3", "3:2"]).optional().describe("기본 4:3"),
|
|
1149
|
+
resolution: z.enum(["720p", "1080p"]).optional().describe("기본 720p"),
|
|
1150
|
+
imageReferenceUrl: z.string().optional().describe("구도/씬 참조 이미지 URL (선택)"),
|
|
1151
|
+
seed: z.number().int().optional().describe("재현용 시드 (선택)"),
|
|
1152
|
+
},
|
|
1153
|
+
wrapCloudHandler(config, async ({ prompt, referenceId, aspectRatio = "4:3", resolution = "720p", imageReferenceUrl = "", seed }) => {
|
|
1154
|
+
const payload = await api.request("/api/tracker/higgsfield/higgsfield-ai/soul/character", {
|
|
1155
|
+
body: {
|
|
1156
|
+
aspect_ratio: aspectRatio,
|
|
1157
|
+
batch_size: 1,
|
|
1158
|
+
custom_reference_id: referenceId,
|
|
1159
|
+
custom_reference_strength: 1,
|
|
1160
|
+
enhance_prompt: false,
|
|
1161
|
+
...(imageReferenceUrl ? { image_reference_url: imageReferenceUrl } : {}),
|
|
1162
|
+
prompt,
|
|
1163
|
+
resolution,
|
|
1164
|
+
...(seed !== undefined ? { seed } : {}),
|
|
1165
|
+
},
|
|
1166
|
+
method: "POST",
|
|
1167
|
+
timeoutMs: 60_000,
|
|
1168
|
+
});
|
|
1169
|
+
const requestId = String(payload?.request_id || payload?.id || "");
|
|
1170
|
+
if (!requestId) return textResult("이미지 생성 요청 응답에 request_id가 없습니다.", { isError: true });
|
|
1171
|
+
return jsonResult({ costUsd: payload?.costUsd, requestId, status: payload?.status });
|
|
1172
|
+
}),
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
server.tool(
|
|
1176
|
+
"influencer_image_status",
|
|
1177
|
+
"소울 이미지 생성 상태를 조회합니다 (terminal: completed | failed | nsfw | canceled). 결과 이미지는 만료 없는 공개 URL입니다.",
|
|
1178
|
+
{ requestId: z.string().min(1) },
|
|
1179
|
+
wrapCloudHandler(config, async ({ requestId }) => {
|
|
1180
|
+
const payload = await api.request(`/api/tracker/higgsfield/requests/${encodeURIComponent(requestId)}/status`, {
|
|
1181
|
+
method: "GET",
|
|
1182
|
+
timeoutMs: 30_000,
|
|
1183
|
+
});
|
|
1184
|
+
return jsonResult({
|
|
1185
|
+
error: payload?.error || payload?.message || undefined,
|
|
1186
|
+
imageUrls: (Array.isArray(payload?.images) ? payload.images : []).map((image) => image?.url).filter(Boolean),
|
|
1187
|
+
progress: payload?.progress,
|
|
1188
|
+
requestId,
|
|
1189
|
+
status: payload?.status,
|
|
1190
|
+
});
|
|
1191
|
+
}),
|
|
1192
|
+
);
|
|
1193
|
+
|
|
1194
|
+
// ── 상세페이지(PDP) ─────────────────────────────────────────────────────────
|
|
1195
|
+
server.tool(
|
|
1196
|
+
"generate_pdp",
|
|
1197
|
+
"상품 사진으로 상세페이지(PDP) HTML을 생성합니다. 분석→카피→라이프스타일 컷 8장→조립까지 서버가 한 번에 처리하며 3~6분 걸립니다. 결과는 HTML 파일 경로로 반환됩니다 (Pro 스튜디오 기능).",
|
|
1198
|
+
{
|
|
1199
|
+
productName: z.string().min(1).describe("상품명"),
|
|
1200
|
+
productImagePaths: z.array(z.string()).min(2).max(5).describe("상품 사진 로컬 경로 2~5장 (첫 장이 메인)"),
|
|
1201
|
+
modelImagePath: z.string().optional().describe("모델 사진 로컬 경로 (선택 — 사용 장면 컷 레퍼런스)"),
|
|
1202
|
+
description: z.string().optional().describe("부가 설명"),
|
|
1203
|
+
targetCustomer: z.string().optional(),
|
|
1204
|
+
priceRange: z.enum(["~1만원", "1~5만원", "5~10만원", "10~30만원", "30~100만원", "100만원~"]).optional(),
|
|
1205
|
+
coreUsp: z.string().optional().describe("핵심 USP — 한 줄씩 줄바꿈 구분"),
|
|
1206
|
+
tone: z.enum(["자동 추천", "프리미엄·럭셔리", "친근·일상적", "전문적·신뢰", "감성·스토리", "트렌디·MZ"]).optional().describe("기본 자동 추천"),
|
|
1207
|
+
usePersonaReviews: z.boolean().optional().describe("기본 false. true면 한국인 페르소나 후기 5건으로 대체 (+30초)"),
|
|
1208
|
+
downloadImages: z.boolean().optional().describe("기본 true — 라이프스타일 컷을 로컬 저장하고 HTML src를 로컬 경로로 재작성(원격 URL 만료 대비)"),
|
|
1209
|
+
},
|
|
1210
|
+
wrapCloudHandler(config, async ({
|
|
1211
|
+
productName,
|
|
1212
|
+
productImagePaths,
|
|
1213
|
+
modelImagePath = "",
|
|
1214
|
+
description = "",
|
|
1215
|
+
targetCustomer = "",
|
|
1216
|
+
priceRange = "",
|
|
1217
|
+
coreUsp = "",
|
|
1218
|
+
tone = "자동 추천",
|
|
1219
|
+
usePersonaReviews = false,
|
|
1220
|
+
downloadImages = true,
|
|
1221
|
+
}) => {
|
|
1222
|
+
for (const filePath of productImagePaths) assertAllowedInputFile(filePath, ALLOWED_IMAGE_EXTS, { kind: "상품 사진" });
|
|
1223
|
+
if (modelImagePath) assertAllowedInputFile(modelImagePath, ALLOWED_IMAGE_EXTS, { kind: "모델 사진" });
|
|
1224
|
+
assertEncodedBudget([...productImagePaths, modelImagePath], { context: "상품/모델 사진" });
|
|
1225
|
+
|
|
1226
|
+
const { result } = await api.requestSse("/api/tracker/pdp/generate", {
|
|
1227
|
+
body: {
|
|
1228
|
+
coreUsp,
|
|
1229
|
+
description,
|
|
1230
|
+
modelImage: modelImagePath ? fileToImagePayload(modelImagePath) : null,
|
|
1231
|
+
priceRange,
|
|
1232
|
+
productImages: productImagePaths.map((filePath) => fileToImagePayload(filePath)),
|
|
1233
|
+
productName,
|
|
1234
|
+
targetCustomer,
|
|
1235
|
+
tone,
|
|
1236
|
+
usePersonaReviews,
|
|
1237
|
+
},
|
|
1238
|
+
timeoutMs: 900_000,
|
|
1239
|
+
});
|
|
1240
|
+
if (!result?.html) return textResult("PDP 생성 결과가 비어 있습니다. 잠시 후 다시 시도하세요.", { isError: true });
|
|
1241
|
+
|
|
1242
|
+
let html = String(result.html);
|
|
1243
|
+
const savedImages = [];
|
|
1244
|
+
if (downloadImages) {
|
|
1245
|
+
// 렌더러가 URL 을 HTML 이스케이프(&→& 등)해 넣었을 수 있으므로 두 형태 모두 치환.
|
|
1246
|
+
const escapeHtml = (value) => value
|
|
1247
|
+
.replaceAll("&", "&")
|
|
1248
|
+
.replaceAll("<", "<")
|
|
1249
|
+
.replaceAll(">", ">")
|
|
1250
|
+
.replaceAll('"', """)
|
|
1251
|
+
.replaceAll("'", "'");
|
|
1252
|
+
for (const image of Array.isArray(result.lifestyleImages) ? result.lifestyleImages : []) {
|
|
1253
|
+
const url = String(image?.url || "");
|
|
1254
|
+
if (!url) continue;
|
|
1255
|
+
try {
|
|
1256
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
1257
|
+
if (!res.ok) continue;
|
|
1258
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1259
|
+
const ext = String(res.headers.get("content-type") || "").includes("png") ? "png" : "jpg";
|
|
1260
|
+
const saved = saveMediaBuffer(config.stateDir, `pdp-image-${image?.idx ?? savedImages.length + 1}.${ext}`, buf);
|
|
1261
|
+
// HTML 은 같은 디렉터리에 저장되므로 상대경로 치환으로 자립형 유지.
|
|
1262
|
+
const local = `./${path.basename(saved.filePath)}`;
|
|
1263
|
+
const before = html;
|
|
1264
|
+
html = html.split(url).join(local).split(escapeHtml(url)).join(local);
|
|
1265
|
+
if (html !== before) savedImages.push(saved.filePath);
|
|
1266
|
+
} catch {
|
|
1267
|
+
// 다운로드 실패한 컷은 원격 URL 유지
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const savedHtml = saveMediaBuffer(config.stateDir, "pdp.html", Buffer.from(html, "utf8"), { contentType: "text/html" });
|
|
1272
|
+
return jsonResult({
|
|
1273
|
+
analysis: trimAnalysis(result.analysis),
|
|
1274
|
+
heroHeadline: result?.copy?.heroHeadline,
|
|
1275
|
+
htmlPath: savedHtml.filePath,
|
|
1276
|
+
imageCount: (Array.isArray(result.lifestyleImages) ? result.lifestyleImages : []).filter((image) => image?.url).length,
|
|
1277
|
+
localImages: savedImages.length,
|
|
1278
|
+
note: "htmlPath와 로컬 이미지들은 48시간 뒤 자동 정리되는 스테이징 폴더에 있습니다. 보존하려면 같은 폴더의 이미지들과 함께 원하는 위치로 복사하세요.",
|
|
1279
|
+
tagline: result?.copy?.tagline,
|
|
1280
|
+
});
|
|
1281
|
+
}),
|
|
1282
|
+
);
|
|
644
1283
|
}
|
package/lib/companion-tools.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
triageCompanion,
|
|
8
8
|
} from "./companion-client.mjs";
|
|
9
9
|
import { detectRemoteEnvironment } from "./config.mjs";
|
|
10
|
-
import { readPayloadHandle } from "./media-store.mjs";
|
|
10
|
+
import { readPayloadHandle, saveMediaBuffer } from "./media-store.mjs";
|
|
11
11
|
import {
|
|
12
12
|
checkRenderPayloadSize,
|
|
13
13
|
sanitizeRenderPayload,
|
|
@@ -58,6 +58,55 @@ export function registerCompanionTools(server, config) {
|
|
|
58
58
|
},
|
|
59
59
|
);
|
|
60
60
|
|
|
61
|
+
server.tool(
|
|
62
|
+
"detect_shots",
|
|
63
|
+
"이 PC의 컴패니언으로 YouTube 영상의 컷(샷) 경계와 대표 프레임을 감지해 shots JSON 파일로 저장합니다. 저장된 파일 경로를 analyze_edit_points의 shotsFiles에 넘기면 실제 컷 기준의 편집점 분석이 됩니다. (다운로드+ffmpeg — 수십 초 걸릴 수 있음)",
|
|
64
|
+
{
|
|
65
|
+
videoId: z.string().describe("YouTube video id"),
|
|
66
|
+
maxShots: z.number().int().min(3).max(18).optional().describe("최대 샷 수, 기본 15 (컴패니언 상한 18)"),
|
|
67
|
+
sceneThreshold: z.number().min(0.08).max(0.9).optional().describe("장면 전환 감도, 기본 0.22"),
|
|
68
|
+
},
|
|
69
|
+
async ({ videoId, maxShots = 15, sceneThreshold = 0.22 }) => {
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(`${config.companionUrl}/api/shots`, {
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
maxShots,
|
|
74
|
+
minGapMs: 1400,
|
|
75
|
+
sceneThreshold,
|
|
76
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
77
|
+
width: 720,
|
|
78
|
+
}),
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
method: "POST",
|
|
81
|
+
signal: AbortSignal.timeout(240_000),
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
return textResult(`샷 감지 실패 (HTTP ${response.status}). 컴패니언 상태를 companion_status로 확인하세요.`, { isError: true });
|
|
85
|
+
}
|
|
86
|
+
const payload = await response.json();
|
|
87
|
+
const shots = Array.isArray(payload?.shots) ? payload.shots : [];
|
|
88
|
+
if (!shots.length) {
|
|
89
|
+
return textResult("샷 감지 결과가 없습니다. sceneThreshold를 낮추거나 다른 영상으로 시도하세요.", { isError: true });
|
|
90
|
+
}
|
|
91
|
+
// 대표 프레임 base64 는 모델 컨텍스트로 돌려주지 않고 파일 핸들로만 전달.
|
|
92
|
+
const saved = saveMediaBuffer(config.stateDir, `shots-${videoId}.json`, Buffer.from(JSON.stringify(shots)));
|
|
93
|
+
const lastEndMs = Number(shots[shots.length - 1]?.endMs) || 0;
|
|
94
|
+
return textResult(JSON.stringify({
|
|
95
|
+
durationSecApprox: lastEndMs ? Math.round(lastEndMs / 100) / 10 : undefined,
|
|
96
|
+
shotCount: shots.length,
|
|
97
|
+
shotsFile: saved.filePath,
|
|
98
|
+
videoId,
|
|
99
|
+
}, null, 2));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const triage = await triageCompanion(config.companionUrl).catch(() => ({ detail: "", state: "not-running" }));
|
|
102
|
+
if (triage.state !== "ready") {
|
|
103
|
+
return textResult(describeCompanionState(triage, remoteEnv), { isError: true });
|
|
104
|
+
}
|
|
105
|
+
return textResult(`샷 감지 오류: ${error?.name === "TimeoutError" ? "시간 초과(4분)" : String(error?.message || error)}`, { isError: true });
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
61
110
|
server.tool(
|
|
62
111
|
"render_start",
|
|
63
112
|
"타임라인 페이로드를 로컬 컴패니언에 제출해 mp4 렌더를 시작합니다. stitch_timeline이 반환한 payloadPath를 넘기는 것이 표준 경로입니다. 즉시 jobId를 반환하며, render_status로 폴링하고 render_result로 파일을 저장하세요. 렌더는 이 PC에서 수행됩니다.",
|
package/lib/config.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
// 프록시 버전 — 서버가 X-AImakeAll-MCP-Version 으로 하한을 강제(426)할 수 있다.
|
|
5
|
-
export const MCP_PROXY_VERSION = "0.
|
|
5
|
+
export const MCP_PROXY_VERSION = "0.3.0";
|
|
6
6
|
|
|
7
7
|
export const DEFAULT_API_BASE = "https://aimakeall.com";
|
|
8
8
|
export const DEFAULT_COMPANION_URL = "http://127.0.0.1:9876";
|