aimakeall-mcp 0.12.0 → 0.13.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 +37 -4
- package/lib/api-client.mjs +21 -3
- package/lib/cloud-tools.mjs +40 -10
- package/lib/companion-tools.mjs +6 -0
- package/lib/config.mjs +1 -1
- package/lib/production-context.mjs +1 -1
- package/lib/review-full-analysis.mjs +302 -0
- package/lib/review-sources.mjs +106 -0
- package/lib/review-tools.mjs +285 -0
- package/lib/tts-workflow.mjs +19 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
|
|
|
16
16
|
"mcpServers": {
|
|
17
17
|
"aimakeall": {
|
|
18
18
|
"command": "npx",
|
|
19
|
-
"args": ["-y", "aimakeall-mcp@0.
|
|
19
|
+
"args": ["-y", "aimakeall-mcp@0.13.0"],
|
|
20
20
|
"env": { "AIMAKEALL_PAT": "aio_pat_..." }
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -55,17 +55,26 @@ plan_shorts_video → (씬마다) generate_scene_image → get_generation_job(co
|
|
|
55
55
|
|
|
56
56
|
## 제작 스타일 유지와 컷컴포저 편집 (0.12.0)
|
|
57
57
|
|
|
58
|
-
아래의 이미지 소스·영상 표현 선택과 TTS 자동 우선순위는 MCP 0.12.0
|
|
58
|
+
아래의 이미지 소스·영상 표현 선택과 TTS 자동 우선순위는 MCP 0.12.0부터 지원합니다. 현재 설정은 `aimakeall-mcp@0.13.0`으로 바꾸고 클라이언트를 다시 연결하세요. 토큰은 그대로 사용할 수 있습니다. 대응 서버와 컴패니언 런타임도 필요하며, 컴패니언에서 렌더 기능 업데이트를 요청하면 최신 설치 프로그램으로 업데이트하세요.
|
|
59
59
|
|
|
60
60
|
- `plan_shorts_video({categoryId:"viral-shorts",durationTargetSec:40,...})`는 `productionProfile`과 `productionPath`를 반환합니다. 이후 추천·TTS·스티치에 **가장 최근 반환된 `productionPath`**를 전달하세요. 제작별 불변 파일이라 동시에 만든 다른 영상의 스타일과 섞이지 않습니다.
|
|
61
61
|
- 바이럴·커뮤니티 프로필은 `casual-social`을 사용합니다. 사건 소재라도 뉴스 브리핑형으로 자동 변경하지 않습니다. TTS의 `text`를 생략하면 저장된 기획 대본을 사용하며, 분명한 뉴스체 회귀는 과금 전 `422 NARRATION_STYLE_DRIFT`로 중단합니다. 자막은 실제 발화와 일치시켜야 하며 자극성을 위해 사실·인용·피해 내용을 꾸미면 안 됩니다.
|
|
62
|
-
- 보이스 공급자 자동 선택은 Typecast → ElevenLabs → Supertone → Edge TTS → Supertonic2 순서입니다. 키/실행 가능 상태를 먼저
|
|
62
|
+
- 보이스 공급자 자동 선택은 Typecast → ElevenLabs → Supertone → Edge TTS → Supertonic2 순서입니다. 키/실행 가능 상태를 먼저 확인하며, 0.13.0부터 확정된 일시 실패에 한해 아래의 안전한 폴백 규칙을 적용합니다. 명시한 목소리는 승인된 대체 보이스 없이 바꾸지 않습니다. 대화형/소셜 목소리를 우선하되 실제 청취 결과라고 주장하지 않습니다. ElevenLabs 외의 공급자 자막은 실제 오디오 길이 기반 추정 타이밍임을 표시하므로 최종 동기화를 확인하세요.
|
|
63
63
|
- `list_composer_presets`에서 실제 편집기의 프리셋과 모션을 조회하세요. `titleStylePresetId`, `subtitleStylePresetId`, `subtitleLines[].stylePresetId`, `titleStyle`, `subtitleStyle`, `subtitleLines[].style`로 서로 다른 제목/본문 계층과 장면별 강조를 설정합니다. 명시 스타일 > 선택 프리셋 > 프로필 기본값 순서입니다.
|
|
64
64
|
- `search_composer_assets` 또는 무료 `recommend_composer_overlays`로 관련 소재를 검토하고 선택한 `assetId`에 `startSec/endSec`, `xPct/yPct`, `widthPct`를 붙여 `stitch_timeline.overlays`로 전달하세요. 좌표는 화면의 백분율 중심점이며 `opacity`는 0~100입니다. SVG 아이콘·이모지, GIF/비디오, 효과음을 타임라인 레이어로 처리합니다. 사건·피해자에 조롱 밈을 강제로 붙이지 않으며, 출처·라이선스 확인이 필요한 소재는 공개 전에 검토해야 합니다.
|
|
65
65
|
- 목표 길이는 실제 TTS 길이로 덮어쓰지 않습니다. 허용 오차 `max(2초, 목표의 10%)`를 벗어나면 경고하고 렌더 전에 차단합니다. 자동 유료 재합성을 하지 않으며, 무음이나 억지 영상 늘이기로 통과시키지 마세요.
|
|
66
66
|
- 커뮤니티/바이럴 제작은 라이브러리 검토 없이 기본 자막만으로 끝내지 않습니다. 오버레이가 없으면 `decorationRationale`에 적합한 소재를 생략한 이유를 기록해야 렌더할 수 있습니다. 관련 없는 장식이나 부적절한 밈을 억지로 넣는 것은 해결이 아닙니다.
|
|
67
67
|
- `review_production`은 구조만 점검합니다. `render_result`의 `qualityStatus`가 `needs-visual-and-listening-review`이면 아직 시각·청취 검증 전입니다. 최종 영상에서 강조색/글자 크기/모션/소재 겹침과 목소리를 직접 확인한 뒤에만 완성 품질을 보고하세요.
|
|
68
68
|
|
|
69
|
+
## 공급자 폴백과 비용 기록 (0.13.0)
|
|
70
|
+
|
|
71
|
+
- `tts_narration`과 `tts_narration_with_captions`는 일반 합성에서 확정된 일시 거절(429/503 등)에 한해 다음 사용 가능한 공급자를 고려합니다. `allowProviderFallback:false`로 끌 수 있습니다. 자동 선택 전 사용자 키로 보이스 목록을 확인하며, 비용 견적만 요청할 때는 이 추가 검증을 하지 않습니다.
|
|
72
|
+
- 보이스를 명시했거나 제작 핸들에 캐스팅이 저장되어 있으면 공급자별 대체 보이스를 사용자가 허용해야 합니다. 예: `fallbackVoices:{elevenlabs:{voiceId:"사용자가-승인한-ID"}}`. 다른 공급자의 보이스/모델/스타일 ID를 그대로 재사용하지 않습니다. 커뮤니티형 제작은 기존처럼 먼저 `recommend_voice`를 사용하세요.
|
|
73
|
+
- 성공 응답의 `provider`, `primaryProvider`, `providerFallback`, `accountCostEvents`로 실제 합성 공급자와 비용을 확인합니다. 폴백 후 새 `productionPath`에 실제 보이스를 저장하며 이전 제작 핸들은 바꾸지 않습니다. 항상 최신 핸들을 다음 단계에 넘기세요.
|
|
74
|
+
- 제출 시간 초과·네트워크 단절·접수 후 결과 불명, 인증·입력·안전정책 오류에는 다른 유료 생성을 자동 실행하지 않습니다. 오류의 `fallbackStatus`, `fallbackReason`, `fallbackAvailable`, 접수된 비용을 확인하고 기존 작업부터 조회하세요. 실패했다고 비용이 없거나 환불되었다는 뜻은 아닙니다.
|
|
75
|
+
- ElevenLabs의 정확한 문자별 타임스탬프를 선택한 요청은 추정 자막으로 대체하지 않습니다. 다른 공급자 일반 합성이 ElevenLabs로 전환되더라도 `timingSource:"estimated-from-audio-duration"`인 자막이 정밀 타임스탬프로 바뀌지는 않습니다. 사용자 PC의 Companion 직접 Edge TTS는 서버 폴백 체인 밖입니다.
|
|
76
|
+
- 이미지·영상·LLM 폴백은 대응 서버가 지원하는 동일 기능/입력 경로에서만 동작합니다. SUNO, 음성 클로닝, 정밀 정렬 등 모든 독점 기능에 호환 대체가 있는 것은 아닙니다. 상태 조회만으로 미확인 유료 작업을 재생성하지 마세요.
|
|
77
|
+
|
|
69
78
|
## 장면 이미지 준비와 영상 표현 선택
|
|
70
79
|
|
|
71
80
|
다음 설정은 0.12.0부터 지원합니다. 기존 0.11.0 클라이언트는 위의 버전 고정 설정을 바꾸고 다시 연결해야 새 입력과 도구 안내를 사용할 수 있습니다. 이미지 모션 렌더에는 해당 기능을 지원하는 최신 컴패니언 런타임도 필요합니다.
|
|
@@ -96,7 +105,31 @@ detect_shots(원본별, 컴패니언) → analyze_edit_points(videos + shotsFile
|
|
|
96
105
|
- `analyze_edit_points`가 돌려주는 영상별 `burnedSubtitle`(박힌 자막 관측)을 `prepare_remake_timeline`의 `sourceVideos[].burnedSubtitle`로 그대로 넘기고 `subtitleCropMode: "auto"`를 주면 자막 박힌 소스만 하단 크롭됩니다.
|
|
97
106
|
- `[N]`/`[SN]` 행의 `audioContent`는 유료 TTS로 합성됩니다 — 실행 전 사용자 확인을 받으세요. 원본 다운로드·렌더는 이 PC의 컴패니언이 수행합니다.
|
|
98
107
|
|
|
99
|
-
##
|
|
108
|
+
## 영화·드라마 리뷰 제작 (0.13.0)
|
|
109
|
+
|
|
110
|
+
이 절은 0.13.0과 대응 서버·컴패니언 런타임에서 사용하는 경로입니다. 기존 설치된 0.12.0에 포함된 기능이 아니므로 위 설정의 버전을 바꾸고 클라이언트를 다시 연결하세요.
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
analyze_review_sources → resumePath → analyze_review_sources(resumePath) 반복
|
|
114
|
+
→ 원본 청크 분석 → 전체 서사 종합 → 원본 영상 재검증 → reviewSourcePath
|
|
115
|
+
→ plan_review_video → productionPath
|
|
116
|
+
→ recommend_voice(productionPath) → prepare_review_timeline(productionPath)
|
|
117
|
+
→ review_production → render_start → render_status → render_result → 시각·청취·스포일러 검토
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- 원본은 YouTube ID/URL 또는 로컬 MP4/MOV/M4V/MKV/WebM, 최대 5편·원본별 24시간·총 500청크입니다. 기본 `analysisScope:"full"`은 Gemini 3.8로 처음부터 끝까지 영상과 오디오를 분석합니다. 컴패니언이 최대 300초/10MiB MP4로 압축하고 구간 사이 15초를 겹칩니다. 큰 원본 파일 하나를 직접 올리지는 않지만, **원작의 전체 내용이 압축 청크로 클라우드에 전송됩니다.** 사용자 업로드·과금 동의를 받은 뒤 `allowCloudVideoUpload:true`를 지정해야 합니다. 동의 없이는 샘플 분석으로 우회하지 않습니다.
|
|
121
|
+
- 시작 예: `analyze_review_sources({sources:[{kind:"local",videoPath:"/path/movie.mp4"}],workTitle:"작품명",allowCloudVideoUpload:true})`. YouTube는 `kind:"youtube",videoId:"URL 또는 ID"`입니다. 첫 호출은 원본·지원 기능 검사와 범위/예상 유료 단계 수만 보여 주고 과금하지 않습니다. 이후 `analyze_review_sources({resumePath,allowCloudVideoUpload:true})`를 `complete:true`까지 반복합니다. 기본 한 호출당 1단계이며 `progress`와 `review_analysis_status({resumePath})`로 진행 상황·누적 비용을 확인합니다.
|
|
122
|
+
- 체크포인트는 원본 식별자·전체 전사·장면·등장인물·서사·단계별 비용을 보존합니다. 오래된 `resumePath`로 재개해도 최신 영수증을 찾아 완료된 청크를 재호출하지 않습니다. 오디오 미검토/전사 누락, 원본 변경, 재검증 모순·근거 부족, 접수 여부가 불확실한 요청은 중단합니다. `failedStageResultPath`에 반환된 분석/반박 근거를 보존하며 자동 재시도하지 않습니다. 추가 비용을 사용자가 승인한 경우에만 `retryFailedStage:true`를 사용하세요.
|
|
123
|
+
- `resumePath`·`reviewSourcePath`·`productionPath` 및 실패 근거는 MCP의 임시 media 저장소에 있으며 기본 48시간 보존 정책의 정리 대상입니다. 첫 체크포인트 생성 후 48시간 안에 이어서 진행하세요. 장기 보관에는 연결된 체크포인트·근거 파일까지 별도 내보내기/백업이 필요하며 무기한 재개를 보장하지 않습니다.
|
|
124
|
+
- 전체 줄거리와 인물 주장은 관련 원본 MP4+음성을 다시 보내 검증합니다. 모든 대상 검증이 지지되어야 전체 분석 핸들이 나옵니다. 이는 모델 판정이며 모든 프레임의 관찰·전사 완전성·사실성을 보증하지 않습니다. 최종 영상·음성과 원본 근거는 사람이 확인해야 합니다.
|
|
125
|
+
- `analysisScope:"sample"`을 명시하면 예전 대표 프레임 점검(최대 18장)만 수행합니다. 원작 전체 이해가 아닙니다. `segments:[{startMs,endMs,note,dialogueText,spoilerLevel}]` 및 `transcriptSegments:[{startMs,endMs,text}]`를 제공할 수 있고, 구간에 완전히 포함된 대사만 인용 근거로 사용합니다. 이 자료로 부분 구간 리뷰를 만들려면 별도로 `allowPartialSourceReview:true`를 명시해야 합니다.
|
|
126
|
+
- 스포일러 등급은 자동으로 안전하다고 인증하지 않습니다. 미확인 구간은 `unknown`이며 `none`/`limited` 기획에서 제외됩니다. 장편의 편집 후보만 최대 500개/180,000자로 제한하고, 검증된 서사 근거 장면을 우선 선택한 내역을 `catalogSelection`으로 알려 줍니다. `sourceIds`로 직접 선택할 수도 있습니다. 전체 서사·전사는 자르지 않고 유지하며, 전편 맥락 한도를 넘으면 작품/회차 분리를 요청합니다.
|
|
127
|
+
- `plan_review_video({reviewSourcePath,workType:"film" 또는 "drama",workTitle,episode?,targetDurationSec:60..900,spoilerPolicy:"none"|"limited"|"full",reviewAngle,deliveryStyle})`로 리뷰 관점과 말투를 선택합니다. 사실·해석·가상 해석은 구분하며 실제 대사 인용은 제공한 원본 근거가 필요합니다.
|
|
128
|
+
- 내레이션 `[N]`, 원본 대사 `[S]`, 현장음 `[A]`를 편집할 수 있습니다. 리뷰 준비는 공통 자막 프리셋·아이콘/이모지 자동 배치를 사용하며 `autoCompose:false`와 명시 스타일/오버레이로 제어할 수 있습니다. 준비된 길이·실측 TTS가 맞지 않으면 자동 재합성이나 억지 늘이기 대신 오류를 확인하세요.
|
|
129
|
+
- TTS 공급자 우선순위는 Typecast → ElevenLabs → Supertone → Edge TTS → Supertonic2입니다. 보이스를 명시했으면 공급자도 지정하세요. 반환된 최신 `productionPath`를 계속 사용하면 동시 제작의 음성·원본이 섞이지 않습니다. 원본이 분석 후 바뀌면 다시 분석해야 합니다.
|
|
130
|
+
- 영상 및 음악의 사용 권리, 인용 내용, 대사·장면 타이밍, 스포일러, 최종 자막과 음성은 공개 전에 직접 검토해야 합니다. 자동 검토를 공개 허가나 품질 보증으로 표시하지 않습니다.
|
|
131
|
+
|
|
132
|
+
## 호출 한도
|
|
100
133
|
|
|
101
134
|
- 쓰기(생성) 호출 분당 20회, 조회 분당 120회, 동시 생성 2개 (서버 강제)
|
|
102
135
|
- 사용자당 활성 PAT 5개, 기본 만료 90일
|
package/lib/api-client.mjs
CHANGED
|
@@ -6,11 +6,21 @@ import { MCP_PROXY_VERSION } from "./config.mjs";
|
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
7
7
|
|
|
8
8
|
export class AimakeallApiError extends Error {
|
|
9
|
-
constructor(message, { errorCode = "", retryAfterSeconds = 0, status = 0 } = {}) {
|
|
9
|
+
constructor(message, { errorCode = "", retryAfterSeconds = 0, status = 0, accountCostEvents, acceptedTaskCosts, operationId, costUsd, usage, partialProviderUsage, fallbackMetadata } = {}) {
|
|
10
10
|
super(message);
|
|
11
11
|
this.errorCode = errorCode;
|
|
12
12
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
13
13
|
this.status = status;
|
|
14
|
+
this.accountCostEvents = Array.isArray(accountCostEvents) ? accountCostEvents : [];
|
|
15
|
+
this.acceptedTaskCosts = Array.isArray(acceptedTaskCosts) ? acceptedTaskCosts : [];
|
|
16
|
+
this.operationId = typeof operationId === "string" ? operationId : "";
|
|
17
|
+
this.costUsd = Number.isFinite(costUsd) ? costUsd : 0;
|
|
18
|
+
this.usage = usage && typeof usage === "object" ? usage : null;
|
|
19
|
+
this.partialProviderUsage = partialProviderUsage === true;
|
|
20
|
+
for (const field of ["fallbackStatus", "fallbackReason", "capability", "provider"]) {
|
|
21
|
+
if (typeof fallbackMetadata?.[field] === "string") this[field] = fallbackMetadata[field].slice(0, 160);
|
|
22
|
+
}
|
|
23
|
+
if (typeof fallbackMetadata?.fallbackAvailable === "boolean") this.fallbackAvailable = fallbackMetadata.fallbackAvailable;
|
|
14
24
|
}
|
|
15
25
|
}
|
|
16
26
|
|
|
@@ -65,9 +75,13 @@ export function createApiClient({ apiBase, pat }) {
|
|
|
65
75
|
|
|
66
76
|
if (!response.ok) {
|
|
67
77
|
throw new AimakeallApiError(describeApiFailure(response.status, payload), {
|
|
68
|
-
errorCode: String(payload?.error || ""),
|
|
78
|
+
errorCode: String(payload?.code || payload?.error || ""),
|
|
69
79
|
retryAfterSeconds: Number(payload?.retryAfterSeconds) || Number(response.headers.get("retry-after")) || 0,
|
|
70
80
|
status: response.status,
|
|
81
|
+
accountCostEvents: payload?.accountCostEvents, acceptedTaskCosts: payload?.acceptedTaskCosts,
|
|
82
|
+
operationId: payload?.operationId, costUsd: payload?.costUsd,
|
|
83
|
+
usage: payload?.usage, partialProviderUsage: payload?.partialProviderUsage,
|
|
84
|
+
fallbackMetadata: payload,
|
|
71
85
|
});
|
|
72
86
|
}
|
|
73
87
|
return payload;
|
|
@@ -98,8 +112,12 @@ export function createApiClient({ apiBase, pat }) {
|
|
|
98
112
|
payload = null;
|
|
99
113
|
}
|
|
100
114
|
throw new AimakeallApiError(describeApiFailure(response.status, payload), {
|
|
101
|
-
errorCode: String(payload?.error || ""),
|
|
115
|
+
errorCode: String(payload?.code || payload?.error || ""),
|
|
102
116
|
status: response.status,
|
|
117
|
+
accountCostEvents: payload?.accountCostEvents, acceptedTaskCosts: payload?.acceptedTaskCosts,
|
|
118
|
+
operationId: payload?.operationId, costUsd: payload?.costUsd,
|
|
119
|
+
usage: payload?.usage, partialProviderUsage: payload?.partialProviderUsage,
|
|
120
|
+
fallbackMetadata: payload,
|
|
103
121
|
});
|
|
104
122
|
}
|
|
105
123
|
return {
|
package/lib/cloud-tools.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob } from "./
|
|
|
27
27
|
import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
|
|
28
28
|
import { trimWavToSeconds } from "./wav-trim.mjs";
|
|
29
29
|
import { registerComposerTools } from "./composer-tools.mjs";
|
|
30
|
+
import { registerReviewTools } from "./review-tools.mjs";
|
|
30
31
|
import { productionFields, visualProductionFields, sceneVisual, composerTextStyle, composerOverlay } from "./production-schemas.mjs";
|
|
31
32
|
import { saveProductionContext, readProductionContext, durationReview, reviewProductionPayload } from "./production-context.mjs";
|
|
32
33
|
import { TTS_PROVIDERS, buildTtsRequestBody, estimateTtsSubtitleLines, requestTtsBinary, resolveTtsSelection } from "./tts-workflow.mjs";
|
|
@@ -38,6 +39,11 @@ const BODY_BUDGET_BYTES = SERVER_JSON_BODY_LIMIT_BYTES - 512 * 1024; // 바디
|
|
|
38
39
|
|
|
39
40
|
// 컴패니언 계약 상수 — companion/index.cjs 의 MAX_SHOT_COUNT 와 맞춘다.
|
|
40
41
|
const COMPANION_MAX_SHOTS = 18;
|
|
42
|
+
const ttsFallbackFields = {
|
|
43
|
+
allowProviderFallback: z.boolean().optional().describe("확정된 일시 거절 때만 대체 공급자 허용. 처리 결과 불명·인증·정책 오류는 재합성하지 않음"),
|
|
44
|
+
fallbackVoices: z.object(Object.fromEntries(TTS_PROVIDERS.map((provider) => [provider,
|
|
45
|
+
z.object({ voiceId: z.string().min(1) }).optional()]))).optional().describe("선택한 목소리를 대신해도 되는 공급자별 보이스 ID. 미지정이면 명시 보이스를 다른 목소리로 변경하지 않음"),
|
|
46
|
+
};
|
|
41
47
|
|
|
42
48
|
function assertEncodedBudget(filePaths, { context = "요청" } = {}) {
|
|
43
49
|
let total = 0;
|
|
@@ -147,13 +153,21 @@ function describeLocalError(error) {
|
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
export function wrapCloudHandler(config, handler) {
|
|
150
|
-
return async (args) => {
|
|
156
|
+
return async (args, extra) => {
|
|
151
157
|
if (!config.pat) return textResult(NO_PAT_GUIDE, { isError: true });
|
|
152
158
|
try {
|
|
153
|
-
return await handler(args);
|
|
159
|
+
return await handler(args, extra);
|
|
154
160
|
} catch (error) {
|
|
155
161
|
if (error instanceof AimakeallApiError) {
|
|
156
162
|
const retry = error.retryAfterSeconds ? ` (${error.retryAfterSeconds}초 후 재시도)` : "";
|
|
163
|
+
if (error.partialProviderUsage || error.accountCostEvents.length || error.fallbackStatus) {
|
|
164
|
+
return textResult(JSON.stringify({ error: error.message, code: error.errorCode, status: error.status,
|
|
165
|
+
provider: error.provider, fallbackStatus: error.fallbackStatus, fallbackReason: error.fallbackReason,
|
|
166
|
+
fallbackAvailable: error.fallbackAvailable, capability: error.capability,
|
|
167
|
+
accountCostEvents: error.accountCostEvents, acceptedTaskCosts: error.acceptedTaskCosts,
|
|
168
|
+
partialProviderUsage: error.partialProviderUsage, usage: error.usage,
|
|
169
|
+
guidance: "접수된 비용과 실패 사유를 확인하세요. 결과 불명 작업이나 지정한 보이스를 자동 재생성/교체하지 마세요." }, null, 2), { isError: true });
|
|
170
|
+
}
|
|
157
171
|
return textResult(`${error.message}${retry}`, { isError: true });
|
|
158
172
|
}
|
|
159
173
|
return textResult(`요청 실패: ${describeLocalError(error)}`, { isError: true });
|
|
@@ -187,6 +201,7 @@ function trimAnalysis(analysis) {
|
|
|
187
201
|
|
|
188
202
|
export function registerCloudTools(server, config, api) {
|
|
189
203
|
registerComposerTools(server, config, api);
|
|
204
|
+
registerReviewTools(server, config, api, { wrap: handler => wrapCloudHandler(config, handler) });
|
|
190
205
|
async function completedGenerationResult(job, includePreview) {
|
|
191
206
|
const payload = job.result || {};
|
|
192
207
|
const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
|
|
@@ -293,7 +308,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
293
308
|
ttsChars: z.number().int().min(0).max(20000).optional().describe("내레이션 글자 수"),
|
|
294
309
|
},
|
|
295
310
|
wrapCloudHandler(config, async (input) => {
|
|
296
|
-
if (input.ttsChars > 0) input = { ...input, ttsProvider: (await resolveTtsSelection(api, config, { provider: input.ttsProvider })).provider };
|
|
311
|
+
if (input.ttsChars > 0) input = { ...input, ttsProvider: (await resolveTtsSelection(api, config, { provider: input.ttsProvider, validateCredentials: false })).provider };
|
|
297
312
|
const payload = await api.request("/api/tracker/cost/estimate", { body: input, method: "POST", timeoutMs: 30_000 });
|
|
298
313
|
return jsonResult(payload);
|
|
299
314
|
}),
|
|
@@ -711,17 +726,18 @@ export function registerCloudTools(server, config, api) {
|
|
|
711
726
|
|
|
712
727
|
server.tool(
|
|
713
728
|
"tts_narration",
|
|
714
|
-
"내레이션 TTS를 합성해 이 PC에
|
|
729
|
+
"내레이션 TTS를 합성해 이 PC에 저장합니다. 자동 순서: Typecast → ElevenLabs → Supertone → Edge → Supertonic2. 확정된 일시 거절에만 폴백하며, 명시 보이스는 fallbackVoices가 있어야 교체합니다. 접수 후 결과 불명·정책·인증 오류는 재합성하지 않습니다. 먼저 recommend_voice로 톤에 맞는 보이스를 고르세요.",
|
|
715
730
|
{
|
|
716
731
|
...productionFields,
|
|
717
732
|
text: z.string().optional().describe("생략 시 productionPath의 기획 대본"),
|
|
733
|
+
...ttsFallbackFields,
|
|
718
734
|
provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동: Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 명시 선택/저장된 보이스는 유지"),
|
|
719
735
|
voice: z.string().optional().describe("보이스 이름(라벨)"),
|
|
720
736
|
speed: z.number().optional().describe("기본 1"),
|
|
721
737
|
stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional(),
|
|
722
738
|
modelId: z.string().optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
|
|
723
739
|
},
|
|
724
|
-
wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost }) => {
|
|
740
|
+
wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, stability, style, modelId, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
|
|
725
741
|
const context = readProductionContext(config.stateDir, productionPath);
|
|
726
742
|
productionProfile = context?.productionProfile || productionProfile;
|
|
727
743
|
text ??= context?.narrationScript;
|
|
@@ -731,13 +747,21 @@ export function registerCloudTools(server, config, api) {
|
|
|
731
747
|
voice ||= context?.voice?.voiceId || "";
|
|
732
748
|
if (!text?.trim()) return textResult("대본(text/productionPath)이 필요합니다.", { isError: true });
|
|
733
749
|
if (productionProfile?.deliveryStyle === "casual-social" && !voice) return textResult("먼저 recommend_voice에 productionPath를 전달해 커뮤니티형 보이스를 선정하세요.", { isError: true });
|
|
734
|
-
const body = buildTtsRequestBody(provider, { productionProfile, costEventId: createUsageEventId(`mcp-tts-${provider}`), modelId, similarityBoost, speakerBoost, speed, stability, style, text, voice });
|
|
750
|
+
const body = buildTtsRequestBody(provider, { productionProfile, costEventId: createUsageEventId(`mcp-tts-${provider}`), modelId, similarityBoost, speakerBoost, speed, stability, style, text, voice, fallbackVoices, allowProviderFallback });
|
|
735
751
|
const result = await requestTtsBinary(api, config, selection, body);
|
|
752
|
+
provider = result.provider || provider;
|
|
753
|
+
voice = result.voiceId || voice;
|
|
736
754
|
const saved = saveMediaBuffer(config.stateDir, provider === "supertonic2" ? "narration.wav" : "narration.mp3", result.buffer, { contentType: result.contentType });
|
|
737
755
|
const durationSec = audioDurationSecFromFile(saved.filePath);
|
|
738
756
|
return jsonResult({
|
|
739
|
-
productionPath: saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text,
|
|
757
|
+
productionPath: saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text,
|
|
758
|
+
...(result.providerFallback ? { voice: { provider, voiceId: voice, reason: "사용자가 허용한 공급자별 대체 보이스" } } : {}),
|
|
759
|
+
tts: { provider, filePath: saved.filePath, durationSec, voiceId: voice } }),
|
|
740
760
|
provider,
|
|
761
|
+
primaryProvider: result.primaryProvider || provider,
|
|
762
|
+
providerFallback: result.providerFallback === true,
|
|
763
|
+
accountCostEvents: result.accountCostEvents || [],
|
|
764
|
+
hasAccountCostEvents: true,
|
|
741
765
|
executionTarget: selection.executionTarget,
|
|
742
766
|
durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec),
|
|
743
767
|
bytes: saved.bytes,
|
|
@@ -1464,17 +1488,18 @@ export function registerCloudTools(server, config, api) {
|
|
|
1464
1488
|
// ── 시간 동기 자막 TTS — 쇼츠 하단자막용 ─────────────────────────────────────
|
|
1465
1489
|
server.tool(
|
|
1466
1490
|
"tts_narration_with_captions",
|
|
1467
|
-
"TTS 오디오와 자막을 만듭니다.
|
|
1491
|
+
"TTS 오디오와 자막을 만듭니다. 순서 Typecast→ElevenLabs→Supertone→Edge→Supertonic2. 명시 목소리는 fallbackVoices 없이 변경하지 않습니다. ElevenLabs 선택 시 실제 char-level 타이밍을 유지하며 해당 정밀 기능은 대체하지 않습니다. 나머지는 실측 오디오 길이 기반 추정 자막이며 추가 STT 과금은 없습니다.",
|
|
1468
1492
|
{
|
|
1469
1493
|
...productionFields,
|
|
1470
1494
|
text: z.string().optional().describe("생략 시 제작 핸들의 기획 대본 그대로 합성. 사실·인용을 바꾸거나 뉴스체로 재작성하지 마세요."),
|
|
1495
|
+
...ttsFallbackFields,
|
|
1471
1496
|
provider: z.enum(["auto", ...TTS_PROVIDERS]).optional().describe("기본 자동 선택 또는 제작 핸들에 선정된 공급자"),
|
|
1472
1497
|
voice: z.string().optional().describe("보이스 이름 또는 ID (생략 시 기본 보이스)"),
|
|
1473
1498
|
speed: z.number().optional().describe("기본 1 (0.7~1.2)"),
|
|
1474
1499
|
modelId: z.string().optional(), stability: z.number().min(0).max(1).optional(),
|
|
1475
1500
|
style: z.number().min(0).max(1).optional(), similarityBoost: z.number().min(0).max(1).optional(), speakerBoost: z.boolean().optional(),
|
|
1476
1501
|
},
|
|
1477
|
-
wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost }) => {
|
|
1502
|
+
wrapCloudHandler(config, async ({ text, provider, voice = "", speed, productionPath, productionProfile, modelId, stability, style, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback }) => {
|
|
1478
1503
|
const context = readProductionContext(config.stateDir, productionPath);
|
|
1479
1504
|
productionProfile = context?.productionProfile || productionProfile;
|
|
1480
1505
|
text ??= context?.narrationScript;
|
|
@@ -1489,16 +1514,21 @@ export function registerCloudTools(server, config, api) {
|
|
|
1489
1514
|
return textResult("커뮤니티형 음성을 위해 먼저 recommend_voice에 productionPath를 전달하고, 반환된 productionPath 또는 보이스 ID를 사용하세요.", { isError: true });
|
|
1490
1515
|
}
|
|
1491
1516
|
if (provider !== "elevenlabs") {
|
|
1492
|
-
const body = buildTtsRequestBody(provider, { productionProfile, text, voice, speed, modelId, costEventId: createUsageEventId(`mcp-tts-${provider}-captions`) });
|
|
1517
|
+
const body = buildTtsRequestBody(provider, { productionProfile, text, voice, speed, modelId, fallbackVoices, allowProviderFallback, costEventId: createUsageEventId(`mcp-tts-${provider}-captions`) });
|
|
1493
1518
|
const result = await requestTtsBinary(api, config, selection, body);
|
|
1519
|
+
provider = result.provider || provider;
|
|
1520
|
+
voice = result.voiceId || voice;
|
|
1494
1521
|
const saved = saveMediaBuffer(config.stateDir, provider === "supertonic2" ? "narration-captions.wav" : "narration-captions.mp3", result.buffer, { contentType: result.contentType });
|
|
1495
1522
|
const durationSec = audioDurationSecFromFile(saved.filePath);
|
|
1496
1523
|
const subtitleLines = estimateTtsSubtitleLines(text, durationSec);
|
|
1497
1524
|
const timingSource = "estimated-from-audio-duration";
|
|
1498
1525
|
const updatedProductionPath = saveProductionContext(config.stateDir, { ...context, productionProfile, narrationScript: text,
|
|
1526
|
+
...(result.providerFallback ? { voice: { provider, voiceId: voice, reason: "사용자가 허용한 공급자별 대체 보이스" } } : {}),
|
|
1499
1527
|
tts: { provider, durationSec, filePath: saved.filePath, subtitleLines, voiceId: voice, timingSource },
|
|
1500
1528
|
});
|
|
1501
1529
|
return jsonResult({ productionPath: updatedProductionPath, productionProfile, provider, executionTarget: selection.executionTarget,
|
|
1530
|
+
primaryProvider: result.primaryProvider || provider, providerFallback: result.providerFallback === true,
|
|
1531
|
+
accountCostEvents: result.accountCostEvents || [], hasAccountCostEvents: true,
|
|
1502
1532
|
durationSec, durationWarning: durationReview(productionProfile?.targetDurationSec, durationSec), filePath: saved.filePath,
|
|
1503
1533
|
lineCount: subtitleLines.length, subtitleLines, timingSource,
|
|
1504
1534
|
warnings: [durationSec > 0 ? "자막은 실측 오디오 길이 기준 추정입니다. 최종 렌더 전 발화와 동기를 확인하세요." : "오디오 길이를 측정하지 못해 자막을 만들지 않았습니다. 저장된 오디오를 확인하세요. 자동 재합성하지 않습니다."],
|
package/lib/companion-tools.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { detectRemoteEnvironment } from "./config.mjs";
|
|
10
10
|
import { durationReview, reviewProductionPayload } from "./production-context.mjs";
|
|
11
11
|
import { readPayloadHandle, saveMediaBuffer } from "./media-store.mjs";
|
|
12
|
+
import { restoreReviewRenderSources } from "./review-sources.mjs";
|
|
12
13
|
import {
|
|
13
14
|
checkRenderPayloadSize,
|
|
14
15
|
sanitizeRenderPayload,
|
|
@@ -176,6 +177,11 @@ export function registerCompanionTools(server, config) {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
const { payload: cleaned, warnings } = sanitizeRenderPayload(resolvedPayload);
|
|
180
|
+
try {
|
|
181
|
+
restoreReviewRenderSources(cleaned, { stateDir: config.stateDir, fromSavedPayload: Boolean(payloadPath) });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return textResult(`리뷰 원본 검증 실패: ${String(error?.message || error)}`, { isError: true });
|
|
184
|
+
}
|
|
179
185
|
const productionReview = reviewProductionPayload(cleaned);
|
|
180
186
|
if (!productionReview.pass) {
|
|
181
187
|
return textResult(JSON.stringify({ error: "PRODUCTION_SPEC_MISMATCH", ...productionReview }, null, 2), { isError: true });
|
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.13.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";
|
|
@@ -11,7 +11,7 @@ export function readProductionContext(stateDir, productionPath) {
|
|
|
11
11
|
if (!productionPath) return null;
|
|
12
12
|
const value = readPayloadHandle(productionPath, { stateDir });
|
|
13
13
|
if (value?.kind !== "aimakeall-production" || value.version !== 1 || !value.productionId) {
|
|
14
|
-
throw new Error("productionPath는
|
|
14
|
+
throw new Error("productionPath는 plan_* 또는 TTS가 반환한 제작 핸들이어야 합니다.");
|
|
15
15
|
}
|
|
16
16
|
return value;
|
|
17
17
|
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { savePayloadHandle } from "./media-store.mjs";
|
|
5
|
+
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
6
|
+
import { inspectReviewLocalFile, readReviewHandle, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
|
|
7
|
+
|
|
8
|
+
const KIND = "aimakeall-review-full-analysis";
|
|
9
|
+
const MAX_CHUNK_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const TEXT_BUDGET = 4 * 1024 * 1024;
|
|
11
|
+
const RECEIPT_KEYS = ["accountCostEvents", "acceptedTaskCosts", "operationId", "costUsd", "usage", "partialProviderUsage", "model"];
|
|
12
|
+
|
|
13
|
+
function fail(code, message) { const error = new Error(message); error.code = code; return error; }
|
|
14
|
+
function textBudget(value) {
|
|
15
|
+
if (Buffer.byteLength(JSON.stringify(value)) > TEXT_BUDGET) throw fail("REVIEW_FULL_CONTEXT_TOO_LARGE", "전체 분석 근거가 4MiB를 초과했습니다. 근거를 몰래 삭제하거나 샘플링하지 않습니다. 작품/회차 단위로 나누어 분석하세요.");
|
|
16
|
+
}
|
|
17
|
+
export function fullReviewChunks(sourceId, durationSec) {
|
|
18
|
+
const durationMs = Math.round(durationSec * 1000);
|
|
19
|
+
if (!Number.isFinite(durationSec) || durationSec <= 0 || durationSec > 86400) throw fail("REVIEW_SOURCE_DURATION_INVALID", "원본별 분석 길이는 실측 24시간 이하이어야 합니다.");
|
|
20
|
+
const chunks = [];
|
|
21
|
+
for (let startMs = 0; startMs < durationMs; startMs += 285000) {
|
|
22
|
+
const endMs = Math.min(durationMs, startMs + 300000);
|
|
23
|
+
chunks.push({ chunkId: `${sourceId}:${startMs}-${endMs}`, sourceId, startMs, endMs });
|
|
24
|
+
if (endMs === durationMs) break;
|
|
25
|
+
}
|
|
26
|
+
return chunks;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function snapshot(config, state) {
|
|
30
|
+
state.revision = (state.revision || 0) + 1;
|
|
31
|
+
if (Buffer.byteLength(JSON.stringify(state)) > 12 * 1024 * 1024) throw fail("REVIEW_CHECKPOINT_TOO_LARGE", "체크포인트 12MiB 한도를 초과했습니다. 분석 근거를 임의로 생략하지 않습니다.");
|
|
32
|
+
return savePayloadHandle(config.stateDir, state, `review-full-${state.analysisId}-${state.revision}`).filePath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadFullReviewAnalysis(config, resumePath) {
|
|
36
|
+
const base = readReviewHandle(config.stateDir, resumePath, KIND);
|
|
37
|
+
if (!/^[a-f0-9-]{36}$/.test(base.analysisId || "")) throw fail("REVIEW_CHECKPOINT_INVALID", "전체 분석 체크포인트 ID가 잘못되었습니다.");
|
|
38
|
+
let state = base, latestPath = resumePath;
|
|
39
|
+
// Immutable journal discovery also finds a receipt saved just before a client
|
|
40
|
+
// disconnect. Resuming an old handle must not charge completed chunks again.
|
|
41
|
+
for (const name of readdirSync(path.join(config.stateDir, "media"))) {
|
|
42
|
+
if (!name.includes(`review-full-${base.analysisId}-`) || !name.endsWith(".json")) continue;
|
|
43
|
+
const filePath = path.join(config.stateDir, "media", name);
|
|
44
|
+
const candidate = readReviewHandle(config.stateDir, filePath, KIND);
|
|
45
|
+
if (candidate.analysisId !== base.analysisId) continue;
|
|
46
|
+
if (candidate.revision > state.revision) { state = candidate; latestPath = filePath; }
|
|
47
|
+
}
|
|
48
|
+
return { state, resumePath: latestPath };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lockAnalysis(config, analysisId) {
|
|
52
|
+
const filePath = path.join(config.stateDir, "media", `review-analysis-${analysisId}.lock`);
|
|
53
|
+
let fd;
|
|
54
|
+
try { fd = openSync(filePath, "wx", 0o600); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== "EEXIST") throw error;
|
|
57
|
+
const pid = Number(readFileSync(filePath, "utf8"));
|
|
58
|
+
try { if (!(pid > 0)) throw new Error("unknown lock owner"); process.kill(pid, 0); }
|
|
59
|
+
catch (probe) {
|
|
60
|
+
if (probe.code === "ESRCH") { unlinkSync(filePath); fd = openSync(filePath, "wx", 0o600); }
|
|
61
|
+
}
|
|
62
|
+
if (fd === undefined) throw fail("REVIEW_ANALYSIS_RUNNING", "같은 전체 분석이 이미 실행 중입니다. 완료를 기다린 후 상태를 조회하세요.");
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(fd, String(process.pid));
|
|
65
|
+
return () => { closeSync(fd); unlinkSync(filePath); };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function receipt(value, stage, chunkId = "") {
|
|
69
|
+
return { stage, chunkId, ...Object.fromEntries(RECEIPT_KEYS.filter(key => value?.[key] !== undefined).map(key => [key, value[key]])) };
|
|
70
|
+
}
|
|
71
|
+
function safeStageResult(value) {
|
|
72
|
+
if (Array.isArray(value)) return value.map(safeStageResult);
|
|
73
|
+
if (!value || typeof value !== "object") return value;
|
|
74
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|token/i.test(key)).map(([key, item]) => [key, safeStageResult(item)]));
|
|
75
|
+
}
|
|
76
|
+
function billing(state) {
|
|
77
|
+
const costs = state.receipts || [];
|
|
78
|
+
const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.eventId || item.id || JSON.stringify(item), item])).values()];
|
|
79
|
+
return { accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts"),
|
|
80
|
+
costUsd: costs.reduce((sum, item) => sum + (Number(item.costUsd) || 0), 0), stageReceipts: costs };
|
|
81
|
+
}
|
|
82
|
+
function summary(state, resumePath) {
|
|
83
|
+
const analyzedMs = state.sources.reduce((sum, source) => {
|
|
84
|
+
let end = 0, covered = 0;
|
|
85
|
+
for (const chunk of state.chunks.filter(item => item.sourceId === source.id && state.analyses[item.chunkId]).sort((a, b) => a.startMs - b.startMs)) {
|
|
86
|
+
covered += Math.max(0, chunk.endMs - Math.max(end, chunk.startMs)); end = Math.max(end, chunk.endMs);
|
|
87
|
+
}
|
|
88
|
+
return sum + covered;
|
|
89
|
+
}, 0);
|
|
90
|
+
return { analysisScope: "full", complete: state.stage === "complete", resumePath,
|
|
91
|
+
reviewSourcePath: state.stage === "complete" ? state.reviewSourcePath : undefined,
|
|
92
|
+
progress: { stage: state.stage, completedChunks: Object.keys(state.analyses).length, totalChunks: state.chunks.length,
|
|
93
|
+
completedVerifications: Object.keys(state.verifications || {}).length, totalVerifications: state.verifyChunkIds?.length || 0,
|
|
94
|
+
analyzedTimelineSec: analyzedMs / 1000, totalTimelineSec: state.sources.reduce((sum, item) => sum + item.durationSec, 0) },
|
|
95
|
+
sources: reviewPublicSources(state.sources), ...billing(state),
|
|
96
|
+
warnings: state.warnings || [], error: state.error || undefined,
|
|
97
|
+
failedStageResultPath: state.failedStageResultPath || undefined,
|
|
98
|
+
requiresHumanReview: true, next: state.stage === "complete" ? "plan_review_video({reviewSourcePath,...})" : state.stage === "blocked" ? "실패 원인·이미 접수된 비용을 확인하세요. 자동 재시도하지 않습니다." : "analyze_review_sources({resumePath,allowCloudVideoUpload:true})로 다음 1개 단계를 계속하세요.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function fullReviewStatus(config, resumePath) { const loaded = loadFullReviewAnalysis(config, resumePath); return summary(loaded.state, loaded.resumePath); }
|
|
102
|
+
|
|
103
|
+
// This limits only the editing candidate list, never the full story/transcript
|
|
104
|
+
// evidence. All native analysis remains in the original immutable handle.
|
|
105
|
+
export function selectReviewPlanningCatalog(handle, requestedIds) {
|
|
106
|
+
const all = handle.sourceCatalog || [], byId = new Map(all.map(scene => [scene.sourceId, scene]));
|
|
107
|
+
const fits = items => items.length <= 500 && JSON.stringify(items).length <= 180000;
|
|
108
|
+
if (requestedIds?.length) {
|
|
109
|
+
if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some(id => !byId.has(id))) throw fail("REVIEW_CATALOG_SELECTION_INVALID", "기획 sourceIds는 분석에서 반환한 장면 ID만 중복 없이 선택하세요.");
|
|
110
|
+
const selected = requestedIds.map(id => byId.get(id));
|
|
111
|
+
if (!fits(selected)) throw fail("REVIEW_CATALOG_SELECTION_LIMIT", "편집 후보는 최대 500개/180,000자입니다. 필요한 원본 장면을 더 좁혀 주세요. 전체 분석은 보존됩니다.");
|
|
112
|
+
return { sourceCatalog: selected, catalogSelection: { totalCount: all.length, selectedCount: selected.length, reason: "explicit-sourceIds", fullContextPreserved: true } };
|
|
113
|
+
}
|
|
114
|
+
if (fits(all)) return { sourceCatalog: all, catalogSelection: { totalCount: all.length, selectedCount: all.length, reason: "all-editing-candidates", fullContextPreserved: true } };
|
|
115
|
+
const evidenceIds = new Set((handle.globalStory?.verificationClaims || handle.globalStory?.timeline || []).flatMap(claim => claim.evidenceIds || []));
|
|
116
|
+
const transcriptEvidence = (handle.fullTranscriptSegments || []).filter(segment => evidenceIds.has(segment.id));
|
|
117
|
+
const selected = all.filter(scene => evidenceIds.has(scene.sourceId) || transcriptEvidence.some(segment => (segment.sourceId || segment.reviewSourceId) === scene.reviewSourceId && segment.startMs >= scene.startMs && segment.endMs <= scene.endMs));
|
|
118
|
+
if (!fits(selected)) throw fail("REVIEW_CATALOG_SELECTION_REQUIRED", "줄거리 근거 장면이 편집 후보 한도를 초과합니다. 전체 분석 핸들은 유지됩니다. plan_review_video.sourceIds로 이번 리뷰에서 사용할 장면을 명시하세요.");
|
|
119
|
+
const seen = new Set(selected.map(scene => scene.sourceId));
|
|
120
|
+
const remaining = all.filter(scene => !seen.has(scene.sourceId));
|
|
121
|
+
const slots = Math.min(500 - selected.length, remaining.length);
|
|
122
|
+
for (let i = 0; i < slots; i++) {
|
|
123
|
+
const candidate = remaining[Math.floor(i * remaining.length / slots)];
|
|
124
|
+
if (fits([...selected, candidate])) selected.push(candidate);
|
|
125
|
+
}
|
|
126
|
+
if (!selected.length) throw fail("REVIEW_CATALOG_SELECTION_REQUIRED", "편집할 장면 ID를 명시하세요.");
|
|
127
|
+
return { sourceCatalog: selected, catalogSelection: { totalCount: all.length, selectedCount: selected.length, reason: "verified-story-evidence-first-plus-time-distributed-editing-candidates", fullContextPreserved: true } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function inspectSource(companionJson, config, source) {
|
|
131
|
+
if (source.kind === "local") inspectReviewLocalFile(source.localPath, source.fingerprint);
|
|
132
|
+
const input = source.kind === "local" ? { videoPath: source.localPath } : { url: source.youtubeUrl };
|
|
133
|
+
const inspected = await companionJson(config, "/api/review/source", input, 600000);
|
|
134
|
+
const durationSec = Number(inspected.durationSec ?? Number(inspected.sourceDurationMs) / 1000);
|
|
135
|
+
if (inspected.ok !== true || !inspected.sourceHandle || !/^[a-f0-9]{64}$/i.test(inspected.sourceFingerprint || "") || typeof inspected.hasAudio !== "boolean") throw fail("REVIEW_INSPECT_INVALID", "전체 영상 분석용 로컬 처리기 업데이트가 필요하거나 원본 검사 결과가 불완전합니다.");
|
|
136
|
+
fullReviewChunks(source.id, durationSec);
|
|
137
|
+
if (source.sourceFingerprint && (source.sourceFingerprint !== inspected.sourceFingerprint || Math.abs(source.durationSec - durationSec) > .001 || source.hasAudio !== inspected.hasAudio)) throw fail("REVIEW_SOURCE_CHANGED", "분석 중 원본의 내용·길이·오디오가 변경되었습니다. 새 분석을 시작하세요.");
|
|
138
|
+
return { ...source, sourceHandle: inspected.sourceHandle, sourceFingerprint: inspected.sourceFingerprint, durationSec, hasAudio: inspected.hasAudio };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function videoChunk(config, companionJson, source, chunk, expectedSha256) {
|
|
142
|
+
const media = await companionJson(config, "/api/review/source-chunk", { sourceHandle: source.sourceHandle, startMs: chunk.startMs, endMs: chunk.endMs, ...(expectedSha256 ? { expectedSha256 } : {}) }, 600000);
|
|
143
|
+
if (media.ok !== true || media.mimeType !== "video/mp4" || typeof media.videoBase64 !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(media.videoBase64) || media.videoBase64.length > Math.ceil(MAX_CHUNK_BYTES / 3) * 4) throw fail("REVIEW_CHUNK_INVALID", "영상 청크는 오디오가 유지된 10MiB 이하 MP4이어야 합니다. 이미지 샘플로 대체하지 않습니다.");
|
|
144
|
+
const bytes = Buffer.from(media.videoBase64, "base64");
|
|
145
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
146
|
+
if (!bytes.length || bytes.length > MAX_CHUNK_BYTES || sha256 !== media.sha256 || (expectedSha256 && sha256 !== expectedSha256)) throw fail("REVIEW_CHUNK_CHANGED", "영상 청크 해시가 원본 분석과 일치하지 않습니다. 과금 분석을 중단합니다.");
|
|
147
|
+
if (media.startMs !== chunk.startMs || media.endMs !== chunk.endMs || !Number.isFinite(media.actualDurationSec) || Math.abs(media.actualDurationSec - (chunk.endMs - chunk.startMs) / 1000) > .25 || media.hasAudio !== source.hasAudio) throw fail("REVIEW_CHUNK_COVERAGE_INVALID", "영상 청크의 실제 길이·구간·오디오가 원본과 일치하지 않습니다. 전체 분석으로 표시하지 않습니다.");
|
|
148
|
+
if (media.sourceFingerprint && media.sourceFingerprint !== source.sourceFingerprint) throw fail("REVIEW_SOURCE_CHANGED", "영상 청크 원본 지문이 변경되었습니다.");
|
|
149
|
+
return { source: { ...reviewPublicSources([source])[0], hasAudio: source.hasAudio }, chunk: { chunkId: chunk.chunkId, startMs: chunk.startMs, endMs: chunk.endMs,
|
|
150
|
+
actualDurationSec: media.actualDurationSec, hasAudio: media.hasAudio, sha256, mimeType: media.mimeType, videoBase64: media.videoBase64 } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function checkedAnalysis(value, chunk, media) {
|
|
154
|
+
const analysis = value.chunkAnalysis || value;
|
|
155
|
+
if (analysis.chunkId !== chunk.chunkId || analysis.sourceId !== chunk.sourceId || !Array.isArray(analysis.sceneCatalog) || !Array.isArray(analysis.transcriptSegments) || !analysis.sceneCatalog.length) throw fail("REVIEW_CHUNK_ANALYSIS_INCOMPLETE", "영상 청크의 장면·대본 분석 결과가 누락되었습니다.");
|
|
156
|
+
// Server returns absolute source times, not chunk-relative timestamps.
|
|
157
|
+
for (const item of [...analysis.sceneCatalog, ...analysis.transcriptSegments]) {
|
|
158
|
+
if (!Number.isFinite(item.startMs) || !Number.isFinite(item.endMs) || item.startMs < chunk.startMs || item.endMs <= item.startMs || item.endMs > chunk.endMs) throw fail("REVIEW_CHUNK_REFERENCE_INVALID", "청크 분석 근거의 원본 타임코드가 잘못되었습니다.");
|
|
159
|
+
}
|
|
160
|
+
if (analysis.coverage?.nativeVideoSubmitted !== true || analysis.coverage.nativeAudioSubmitted !== media.chunk.hasAudio || analysis.coverage.startMs !== chunk.startMs || analysis.coverage.endMs !== chunk.endMs || (media.chunk.hasAudio && analysis.coverage.transcriptComplete !== true)) throw fail("REVIEW_AUDIO_ANALYSIS_INCOMPLETE", "영상·음성 분석 범위 또는 전사에 미검토/불명확 구간이 있습니다. 대본을 생략한 채 전체 이해로 넘어가지 않습니다. 해당 구간과 접수 비용을 확인하세요.");
|
|
161
|
+
const { videoBase64, chunk: ignoredChunk, source: ignoredSource, ...safe } = analysis;
|
|
162
|
+
return { ...safe, chunkId: chunk.chunkId, sourceId: chunk.sourceId, startMs: chunk.startMs, endMs: chunk.endMs, sha256: media.chunk.sha256, hasAudio: media.chunk.hasAudio };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function verificationGroups(state) {
|
|
166
|
+
const timeline = state.storyResult?.story?.verificationClaims || state.storyResult?.story?.timeline;
|
|
167
|
+
if (!Array.isArray(timeline) || !timeline.length) throw fail("REVIEW_STORY_INCOMPLETE", "전체 서사에 원본 근거를 연결한 사건·해석이 없습니다.");
|
|
168
|
+
const evidenceToChunk = new Map();
|
|
169
|
+
for (const chunk of Object.values(state.analyses)) for (const item of [...chunk.sceneCatalog, ...chunk.transcriptSegments]) {
|
|
170
|
+
for (const id of [item.id, item.sourceId, item.evidenceId]) if (id) evidenceToChunk.set(id, chunk.chunkId);
|
|
171
|
+
}
|
|
172
|
+
const groups = new Set();
|
|
173
|
+
for (const claim of timeline) {
|
|
174
|
+
if (!claim.id || !Array.isArray(claim.evidenceIds) || !claim.evidenceIds.length) throw fail("REVIEW_STORY_UNGROUNDED", "원본 근거 없는 줄거리 주장이 있어 완료할 수 없습니다.");
|
|
175
|
+
for (const id of claim.evidenceIds) {
|
|
176
|
+
const chunkId = evidenceToChunk.get(id);
|
|
177
|
+
if (!chunkId) throw fail("REVIEW_STORY_UNGROUNDED", "전체 줄거리의 근거 ID가 원본 분석에 없습니다.");
|
|
178
|
+
groups.add(chunkId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [...groups];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function runFullReviewAnalysis({ config, api, args, companionJson, onProgress }) {
|
|
185
|
+
if (args.allowCloudVideoUpload !== true) throw fail("REVIEW_VIDEO_UPLOAD_CONSENT_REQUIRED", "전체 원작 이해에는 모든 구간의 압축 MP4와 음성을 클라우드 AI에 전송하며 분석·검증 비용이 발생합니다. 사용자 동의를 받은 뒤 allowCloudVideoUpload:true를 지정하세요. 동의 없이 대표 이미지 분석으로 바꾸지 않습니다.");
|
|
186
|
+
if (!args.resumePath) {
|
|
187
|
+
if (!args.sources?.length) throw fail("REVIEW_SOURCES_REQUIRED", "전체 분석할 sources 또는 재개할 resumePath가 필요합니다.");
|
|
188
|
+
const health = await companionJson(config, "/health", undefined, 15000);
|
|
189
|
+
if (health.capabilities?.reviewSourceAnalysis !== true || health.capabilities?.reviewSourceChunks !== true || health.capabilities.reviewSourceChunkMaxDurationSec < 300 || health.capabilities.reviewSourceChunkMaxBytes < MAX_CHUNK_BYTES) throw fail("COMPANION_UPDATE_REQUIRED", "전체 영상·음성 분석 청크를 지원하는 로컬 처리기로 업데이트하세요. 대표 프레임으로 우회하지 않습니다.");
|
|
190
|
+
const state = { kind: KIND, version: 1, analysisId: randomUUID(), revision: 0, stage: "analyzing", workTitle: args.workTitle || "",
|
|
191
|
+
sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [], warnings: ["전체 구간을 압축 영상+오디오로 분석하고 서사 주장을 실제 원본 구간으로 재검증합니다. 공개 권리와 최종 해석은 사람이 확인해야 합니다."], uploadConsent: true };
|
|
192
|
+
const fingerprints = args.sources.map(source => source.kind === "local" ? inspectReviewLocalFile(source.videoPath) : null);
|
|
193
|
+
for (const [index, input] of args.sources.entries()) {
|
|
194
|
+
const id = `review-${randomUUID().slice(0, 8)}`;
|
|
195
|
+
const source = { id, kind: input.kind, title: input.title || `리뷰 원본 ${index + 1}`, videoId: "",
|
|
196
|
+
...(input.kind === "local" ? { localPath: fingerprints[index].path, fingerprint: fingerprints[index] } : { youtubeUrl: canonicalYoutubeWatchUrl(input.videoId), videoId: new URL(canonicalYoutubeWatchUrl(input.videoId)).searchParams.get("v") }) };
|
|
197
|
+
const inspected = await inspectSource(companionJson, config, source);
|
|
198
|
+
state.sources.push(inspected); state.chunks.push(...fullReviewChunks(id, inspected.durationSec));
|
|
199
|
+
}
|
|
200
|
+
if (state.chunks.length > 500) throw fail("REVIEW_CHUNK_COUNT_LIMIT", "전체 분석은 최대 500개 청크입니다. 과금 호출 전에 작품/회차 단위로 나누세요.");
|
|
201
|
+
validateReviewSources(state.sources);
|
|
202
|
+
const resumePath = snapshot(config, state);
|
|
203
|
+
return { ...summary(state, resumePath), preparedOnly: true, estimatedPaidStages: { chunkAnalyses: state.chunks.length, storySynthesis: 1, sourceVerificationsMaximum: state.chunks.length },
|
|
204
|
+
next: "원본 범위·업로드 및 분석 비용을 확인한 뒤 analyze_review_sources({resumePath,allowCloudVideoUpload:true})를 호출하세요. 아직 유료 분석은 실행하지 않았습니다." };
|
|
205
|
+
}
|
|
206
|
+
if (args.sources?.length) throw fail("REVIEW_RESUME_SOURCE_CONFLICT", "재개할 때 sources를 바꾸지 마세요. 새 원본은 별도의 분석으로 시작하세요.");
|
|
207
|
+
const loaded = loadFullReviewAnalysis(config, args.resumePath);
|
|
208
|
+
const release = lockAnalysis(config, loaded.state.analysisId);
|
|
209
|
+
let state, resumePath;
|
|
210
|
+
try {
|
|
211
|
+
({ state, resumePath } = loadFullReviewAnalysis(config, args.resumePath));
|
|
212
|
+
validateReviewSources(state.sources);
|
|
213
|
+
if (state.stage === "complete") return summary(state, resumePath);
|
|
214
|
+
if (state.inFlight || state.stage === "blocked") {
|
|
215
|
+
if (args.retryFailedStage !== true) return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
|
|
216
|
+
state.stage = state.failedStage || state.inFlight?.stage || "analyzing";
|
|
217
|
+
delete state.inFlight; delete state.error;
|
|
218
|
+
resumePath = snapshot(config, state);
|
|
219
|
+
}
|
|
220
|
+
const refreshed = new Set();
|
|
221
|
+
const getMedia = async (chunk, expectedSha256) => {
|
|
222
|
+
const index = state.sources.findIndex(source => source.id === chunk.sourceId);
|
|
223
|
+
try { return await videoChunk(config, companionJson, state.sources[index], chunk, expectedSha256); }
|
|
224
|
+
catch (error) {
|
|
225
|
+
const expired = error.status === 410 || ["REVIEW_SOURCE_HANDLE_EXPIRED", "REVIEW_SOURCE_HANDLE_INVALID", "REVIEW_SOURCE_HANDLE_NOT_FOUND"].includes(error.code);
|
|
226
|
+
if (!expired || refreshed.has(chunk.sourceId)) throw error;
|
|
227
|
+
state.sources[index] = await inspectSource(companionJson, config, state.sources[index]); refreshed.add(chunk.sourceId);
|
|
228
|
+
return videoChunk(config, companionJson, state.sources[index], chunk, expectedSha256);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const progress = async () => {
|
|
232
|
+
if (!onProgress) return;
|
|
233
|
+
try { await onProgress(summary(state, resumePath)); } catch { /* Progress delivery cannot replay/abort an accepted paid operation. */ }
|
|
234
|
+
};
|
|
235
|
+
const charge = async (stage, chunkId, route, body) => {
|
|
236
|
+
state.inFlight = { stage, chunkId, operationId: randomUUID() }; state.failedStage = stage;
|
|
237
|
+
resumePath = snapshot(config, state);
|
|
238
|
+
await progress();
|
|
239
|
+
let response;
|
|
240
|
+
try { response = await api.request(route, { method: "POST", body: { ...body, costEventId: state.inFlight.operationId }, timeoutMs: 600000 }); }
|
|
241
|
+
catch (error) { state.receipts.push(receipt(error, stage, chunkId)); throw error; }
|
|
242
|
+
state.receipts.push(receipt(response, stage, chunkId));
|
|
243
|
+
state.pendingStageResult = safeStageResult(response);
|
|
244
|
+
return response;
|
|
245
|
+
};
|
|
246
|
+
for (let step = 0; step < (args.maxSteps ?? 1); step++) {
|
|
247
|
+
if (state.stage === "analyzing") {
|
|
248
|
+
const chunk = state.chunks.find(item => !state.analyses[item.chunkId]);
|
|
249
|
+
if (!chunk) { state.stage = "synthesizing"; resumePath = snapshot(config, state); step--; continue; }
|
|
250
|
+
const media = await getMedia(chunk);
|
|
251
|
+
media.workTitle = state.workTitle;
|
|
252
|
+
const previous = Object.values(state.analyses).filter(item => item.sourceId === chunk.sourceId && item.startMs < chunk.startMs).sort((a, b) => b.startMs - a.startMs)[0];
|
|
253
|
+
if (previous) media.priorContext = JSON.stringify({ previousSummary: previous.summary, observedCharacters: previous.characters }).slice(0, 12000);
|
|
254
|
+
const response = await charge("analyzing", chunk.chunkId, "/api/tracker/review/analyze-chunk", media);
|
|
255
|
+
state.analyses[chunk.chunkId] = checkedAnalysis(response, chunk, media);
|
|
256
|
+
} else if (state.stage === "synthesizing") {
|
|
257
|
+
const body = { sources: reviewPublicSources(state.sources), chunks: Object.values(state.analyses), workTitle: state.workTitle };
|
|
258
|
+
textBudget(body);
|
|
259
|
+
state.storyResult = await charge("synthesizing", "", "/api/tracker/review/synthesize-story", body);
|
|
260
|
+
state.verifyChunkIds = verificationGroups(state);
|
|
261
|
+
state.stage = "verifying";
|
|
262
|
+
} else if (state.stage === "verifying") {
|
|
263
|
+
const id = state.verifyChunkIds.find(chunkId => !state.verifications[chunkId]);
|
|
264
|
+
if (!id) { state.stage = "finalizing"; step--; continue; }
|
|
265
|
+
const chunk = state.chunks.find(item => item.chunkId === id), analyzed = state.analyses[id];
|
|
266
|
+
const media = await getMedia(chunk, analyzed.sha256);
|
|
267
|
+
const response = await charge("verifying", id, "/api/tracker/review/verify-evidence", { story: state.storyResult.story, chunks: Object.values(state.analyses), chunk: media });
|
|
268
|
+
if (response.chunkId !== id || !Array.isArray(response.verdicts) || !response.verdicts.length || response.verdicts.some(item => item.status !== "supported")) throw fail("REVIEW_ORIGINAL_VERIFICATION_FAILED", "원본 재검증에서 모순되거나 근거가 부족한 주장이 발견되었습니다. 전체 이해 완료나 자동 기획으로 진행하지 않습니다. 사용자가 줄거리·근거를 검토해야 합니다.");
|
|
269
|
+
state.verifications[id] = response;
|
|
270
|
+
} else if (state.stage === "finalizing") {
|
|
271
|
+
const completed = { kind: "aimakeall-review-sources", version: 1, analysisScope: "full", analysisId: state.analysisId,
|
|
272
|
+
sources: state.sources, sourceCatalog: Object.values(state.analyses).flatMap(chunk => chunk.sceneCatalog), globalStory: state.storyResult.story,
|
|
273
|
+
fullTranscriptSegments: (state.storyResult.transcriptSegments || []).map(segment => {
|
|
274
|
+
const owner = Object.values(state.analyses).find(chunk => chunk.transcriptSegments.some(item => item.id === segment.id));
|
|
275
|
+
if (!owner) throw fail("REVIEW_TRANSCRIPT_REFERENCE_INVALID", "전체 전사의 원본 근거 ID가 없습니다.");
|
|
276
|
+
return { ...segment, sourceId: owner.sourceId, reviewSourceId: owner.sourceId };
|
|
277
|
+
}), coverage: state.storyResult.coverage,
|
|
278
|
+
originalVerification: { complete: true, results: Object.values(state.verifications) }, warnings: state.warnings, receipts: state.receipts };
|
|
279
|
+
if (!Array.isArray(completed.sourceCatalog) || !completed.sourceCatalog.length || !Array.isArray(completed.fullTranscriptSegments)) throw fail("REVIEW_FULL_RESULT_INCOMPLETE", "전체 분석 카탈로그 또는 대본이 없어 완료할 수 없습니다.");
|
|
280
|
+
textBudget(completed);
|
|
281
|
+
state.reviewSourcePath = savePayloadHandle(config.stateDir, completed, "review-sources-full").filePath;
|
|
282
|
+
state.stage = "complete";
|
|
283
|
+
}
|
|
284
|
+
delete state.inFlight; delete state.failedStage;
|
|
285
|
+
delete state.pendingStageResult;
|
|
286
|
+
resumePath = snapshot(config, state);
|
|
287
|
+
await progress();
|
|
288
|
+
if (state.stage === "complete") break;
|
|
289
|
+
}
|
|
290
|
+
return summary(state, resumePath);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!state) throw error;
|
|
293
|
+
state.failedStage ||= state.stage; state.stage = "blocked";
|
|
294
|
+
state.error = { code: error.errorCode || error.code || "REVIEW_FULL_ANALYSIS_FAILED", message: String(error.message || error) };
|
|
295
|
+
if (state.pendingStageResult) {
|
|
296
|
+
state.failedStageResultPath = savePayloadHandle(config.stateDir, { kind: "aimakeall-review-failed-stage", version: 1, analysisId: state.analysisId, stage: state.failedStage, result: state.pendingStageResult }, "review-failed-stage").filePath;
|
|
297
|
+
delete state.pendingStageResult;
|
|
298
|
+
}
|
|
299
|
+
resumePath = snapshot(config, state);
|
|
300
|
+
return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
|
|
301
|
+
} finally { release(); }
|
|
302
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { canonicalYoutubeWatchUrl, sanitizeRenderPayload } from "./payload-guard.mjs";
|
|
4
|
+
import { readPayloadHandle, savePayloadHandle } from "./media-store.mjs";
|
|
5
|
+
|
|
6
|
+
export const REVIEW_EVIDENCE_BUDGET = 12 * 1024 * 1024;
|
|
7
|
+
export const REVIEW_MAX_FRAMES = 18; // existing /shots and video-analysis ceiling
|
|
8
|
+
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".mkv", ".webm"]);
|
|
9
|
+
|
|
10
|
+
// Read only a container header; never read/upload the whole movie. Resolve symlinks
|
|
11
|
+
// before fingerprinting so a changed target cannot silently enter a later render.
|
|
12
|
+
export function inspectReviewLocalFile(filePath, expected = null) {
|
|
13
|
+
const resolved = realpathSync(String(filePath || ""));
|
|
14
|
+
if (!VIDEO_EXTENSIONS.has(path.extname(resolved).toLowerCase())) throw new Error("리뷰 원본은 MP4/MOV/M4V/MKV/WebM 영상 파일이어야 합니다.");
|
|
15
|
+
const stat = statSync(resolved);
|
|
16
|
+
if (!stat.isFile() || stat.size < 12 || stat.size > 64 * 1024 ** 3) throw new Error("리뷰 원본은 64GB 이하의 정상 영상 파일이어야 합니다.");
|
|
17
|
+
const fd = openSync(resolved, "r");
|
|
18
|
+
const header = Buffer.alloc(16);
|
|
19
|
+
try { readSync(fd, header, 0, header.length, 0); } finally { closeSync(fd); }
|
|
20
|
+
if (header.toString("ascii", 4, 8) !== "ftyp" && header.readUInt32BE(0) !== 0x1a45dfa3) throw new Error("파일의 실제 영상 컨테이너 형식을 확인하지 못했습니다.");
|
|
21
|
+
const fingerprint = { path: resolved, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
22
|
+
if (expected && (expected.path !== resolved || expected.size !== stat.size || expected.mtimeMs !== stat.mtimeMs)) throw new Error("분석 후 로컬 원본이 변경되었습니다. 원본 분석을 다시 진행하세요.");
|
|
23
|
+
return fingerprint;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readReviewHandle(stateDir, filePath, kind = "aimakeall-review-sources") {
|
|
27
|
+
const media = realpathSync(path.join(stateDir, "media"));
|
|
28
|
+
const resolved = realpathSync(String(filePath || ""));
|
|
29
|
+
if (!resolved.startsWith(media + path.sep)) throw new Error("리뷰 핸들은 이 MCP가 저장한 media 파일이어야 합니다.");
|
|
30
|
+
if (statSync(resolved).size > REVIEW_EVIDENCE_BUDGET) throw new Error("리뷰 핸들 크기 한도를 초과했습니다.");
|
|
31
|
+
// The realpath containment check above also handles macOS /var → /private/var.
|
|
32
|
+
const value = readPayloadHandle(resolved);
|
|
33
|
+
if (value?.kind !== kind || value.version !== 1) throw new Error("올바른 리뷰 핸들이 아닙니다.");
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function reviewPublicSources(sources) {
|
|
38
|
+
return sources.map(source => ({ id: source.id, kind: source.kind, videoId: source.videoId || "", title: source.title || "", durationSec: source.durationSec }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function assertReviewBudget(value) {
|
|
42
|
+
if (Buffer.byteLength(JSON.stringify(value), "utf8") > REVIEW_EVIDENCE_BUDGET) throw new Error("리뷰 분석 자료가 12MB를 초과했습니다. 원본 수나 프레임 수를 줄이세요. 원본 전체 업로드는 하지 않습니다.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateReviewSources(sources) {
|
|
46
|
+
if (!Array.isArray(sources) || !sources.length || sources.length > 5) throw new Error("리뷰 원본은 1~5개가 필요합니다.");
|
|
47
|
+
const ids = new Set();
|
|
48
|
+
for (const source of sources) {
|
|
49
|
+
if (!source?.id || ids.has(source.id)) throw new Error("리뷰 원본 ID가 없거나 중복입니다.");
|
|
50
|
+
ids.add(source.id);
|
|
51
|
+
if (!Number.isFinite(source.durationSec) || !(source.durationSec > 0) || source.durationSec > 86400) throw new Error("리뷰 원본 길이는 24시간 이하의 실측값이어야 합니다.");
|
|
52
|
+
if (source.kind === "local") {
|
|
53
|
+
if (!source.fingerprint || !Number.isFinite(source.fingerprint.size) || !Number.isFinite(source.fingerprint.mtimeMs)) throw new Error("로컬 리뷰 원본의 분석 지문이 없습니다.");
|
|
54
|
+
inspectReviewLocalFile(source.localPath, source.fingerprint);
|
|
55
|
+
}
|
|
56
|
+
else if (source.kind !== "youtube" || !canonicalYoutubeWatchUrl(source.videoId)) throw new Error("리뷰 원본 유형 또는 YouTube ID가 올바르지 않습니다.");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The server may return only opaque source IDs. Never trust a server-supplied
|
|
61
|
+
// local path/binding. The bindings below are created solely from a local handle.
|
|
62
|
+
export function bindReviewRenderSources(stateDir, serverPayload, reviewSourcePath) {
|
|
63
|
+
const sourcesHandle = readReviewHandle(stateDir, reviewSourcePath);
|
|
64
|
+
validateReviewSources(sourcesHandle.sources);
|
|
65
|
+
const { payload, warnings } = sanitizeRenderPayload(serverPayload);
|
|
66
|
+
delete payload.reviewBindingPath;
|
|
67
|
+
const sources = new Map(sourcesHandle.sources.map(source => [source.id, source]));
|
|
68
|
+
const bindings = [];
|
|
69
|
+
const assetIds = new Set();
|
|
70
|
+
for (const asset of payload.assets || []) {
|
|
71
|
+
if (!asset?.id || assetIds.has(asset.id)) throw new Error("리뷰 렌더 자산 ID가 없거나 중복입니다.");
|
|
72
|
+
assetIds.add(asset.id);
|
|
73
|
+
if (!asset.reviewSourceId) continue;
|
|
74
|
+
if (asset.kind !== "video" || !sources.has(asset.reviewSourceId)) throw new Error("서버가 알 수 없는 리뷰 원본을 참조했습니다.");
|
|
75
|
+
for (const key of ["path", "url", "sourceUrl", "dataUrl", "youtubeUrl", "companionFileUrl"]) delete asset[key];
|
|
76
|
+
bindings.push({ assetId: asset.id, sourceId: asset.reviewSourceId });
|
|
77
|
+
}
|
|
78
|
+
if (!bindings.length) throw new Error("리뷰 렌더에 원본 영상 바인딩이 없습니다.");
|
|
79
|
+
payload.reviewBindingPath = savePayloadHandle(stateDir, {
|
|
80
|
+
kind: "aimakeall-review-bindings", version: 1, reviewSourcePath, bindings,
|
|
81
|
+
}, "review-bindings").filePath;
|
|
82
|
+
// Validate now, but paths are attached again only at local render submission.
|
|
83
|
+
restoreReviewRenderSources(structuredClone(payload), { stateDir, fromSavedPayload: true });
|
|
84
|
+
return { payload, warnings };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function restoreReviewRenderSources(payload, { stateDir, fromSavedPayload = false } = {}) {
|
|
88
|
+
if (!payload.reviewBindingPath) return payload;
|
|
89
|
+
if (!fromSavedPayload) throw new Error("로컬 리뷰 원본은 prepare_review_timeline이 반환한 payloadPath로만 렌더할 수 있습니다.");
|
|
90
|
+
const handle = readReviewHandle(stateDir, payload.reviewBindingPath, "aimakeall-review-bindings");
|
|
91
|
+
const sourcesHandle = readReviewHandle(stateDir, handle.reviewSourcePath);
|
|
92
|
+
validateReviewSources(sourcesHandle.sources);
|
|
93
|
+
const sources = new Map(sourcesHandle.sources.map(source => [source.id, source]));
|
|
94
|
+
const assets = new Map((payload.assets || []).map(asset => [asset.id, asset]));
|
|
95
|
+
if (assets.size !== (payload.assets || []).length || !Array.isArray(handle.bindings) || !handle.bindings.length) throw new Error("리뷰 자산 또는 원본 바인딩이 없거나 중복입니다.");
|
|
96
|
+
const boundAssetIds = new Set(handle.bindings.map(binding => binding.assetId));
|
|
97
|
+
if (boundAssetIds.size !== handle.bindings.length || [...assets.values()].some(asset => asset.reviewSourceId && !boundAssetIds.has(asset.id))) throw new Error("리뷰 원본 바인딩이 변경되었거나 누락되었습니다.");
|
|
98
|
+
for (const binding of handle.bindings || []) {
|
|
99
|
+
const source = sources.get(binding.sourceId), asset = assets.get(binding.assetId);
|
|
100
|
+
if (!source || !asset || asset.kind !== "video" || asset.reviewSourceId !== source.id) throw new Error("리뷰 원본 바인딩이 변경되었거나 일치하지 않습니다.");
|
|
101
|
+
for (const key of ["path", "url", "sourceUrl", "dataUrl", "youtubeUrl", "companionFileUrl"]) delete asset[key];
|
|
102
|
+
if (source.kind === "local") asset.path = inspectReviewLocalFile(source.localPath, source.fingerprint).path;
|
|
103
|
+
else asset.youtubeUrl = canonicalYoutubeWatchUrl(source.videoId);
|
|
104
|
+
}
|
|
105
|
+
return payload;
|
|
106
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { composerOverlay, composerTextStyle } from "./production-schemas.mjs";
|
|
4
|
+
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
5
|
+
import { savePayloadHandle } from "./media-store.mjs";
|
|
6
|
+
import { readProductionContext, saveProductionContext } from "./production-context.mjs";
|
|
7
|
+
import { TTS_PROVIDERS, buildTtsRequestBody, requestTtsBinary, resolveTtsSelection } from "./tts-workflow.mjs";
|
|
8
|
+
import { wavDurationSec } from "./audio-duration.mjs";
|
|
9
|
+
import { mp3DurationSec } from "./mp3-duration.mjs";
|
|
10
|
+
import { createUsageEventId } from "./usage-event.mjs";
|
|
11
|
+
import { assertReviewBudget, bindReviewRenderSources, inspectReviewLocalFile, readReviewHandle, REVIEW_MAX_FRAMES, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
|
|
12
|
+
import { fullReviewStatus, runFullReviewAnalysis, selectReviewPlanningCatalog } from "./review-full-analysis.mjs";
|
|
13
|
+
|
|
14
|
+
const spoiler = z.enum(["none", "limited", "full", "unknown"]);
|
|
15
|
+
const interval = z.object({ startMs: z.number().int().min(0), endMs: z.number().int().positive(),
|
|
16
|
+
note: z.string().max(2000).optional(), dialogueText: z.string().max(2000).optional(),
|
|
17
|
+
spoilerLevel: spoiler.optional(),
|
|
18
|
+
}).refine(value => value.endMs > value.startMs, "구간 끝은 시작 이후여야 합니다.");
|
|
19
|
+
const transcript = z.object({ startMs: z.number().int().min(0), endMs: z.number().int().positive(), text: z.string().max(2000) })
|
|
20
|
+
.refine(value => value.endMs > value.startMs, "대본 구간 끝은 시작 이후여야 합니다.");
|
|
21
|
+
const sourceInput = z.object({
|
|
22
|
+
kind: z.enum(["youtube", "local"]), videoId: z.string().optional(), videoPath: z.string().optional(),
|
|
23
|
+
title: z.string().max(300).optional(), segments: z.array(interval).max(REVIEW_MAX_FRAMES).optional(),
|
|
24
|
+
transcriptSegments: z.array(transcript).max(200).optional(),
|
|
25
|
+
}).superRefine((source, ctx) => {
|
|
26
|
+
if (source.kind === "local" ? (!source.videoPath || source.videoId) : (!canonicalYoutubeWatchUrl(source.videoId) || source.videoPath)) {
|
|
27
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "local은 videoPath만, youtube는 videoId 또는 YouTube URL만 지정하세요." });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const result = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
31
|
+
const COVERAGE_WARNING = "대표 프레임과 제공된 구간 메모/대본만 분석했습니다. 원작 전체를 시청·전사한 결과가 아니며 사건·대사·스포일러는 공개 전에 직접 확인해야 합니다.";
|
|
32
|
+
|
|
33
|
+
function processReviewResponse(receipt, process) {
|
|
34
|
+
try { return process(); }
|
|
35
|
+
catch (error) {
|
|
36
|
+
for (const key of ["accountCostEvents", "acceptedTaskCosts", "operationId", "costUsd", "usage", "partialProviderUsage"]) {
|
|
37
|
+
if (receipt?.[key] !== undefined) error[key] = receipt[key];
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function companionJson(config, route, body, timeoutMs = 240_000, retryBusy = true) {
|
|
44
|
+
if (!config.companionUrl) throw new Error("리뷰 원본 분석에는 로컬 컴패니언이 필요합니다.");
|
|
45
|
+
const response = await fetch(`${config.companionUrl}${route}`, {
|
|
46
|
+
method: route === "/health" ? "GET" : "POST", headers: { "Content-Type": "application/json" }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(timeoutMs),
|
|
47
|
+
});
|
|
48
|
+
if (response.status === 429 && retryBusy && route.startsWith("/api/review/")) {
|
|
49
|
+
// Local extraction cooldown only; never retry a paid cloud request.
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 550));
|
|
51
|
+
return companionJson(config, route, body, timeoutMs, false);
|
|
52
|
+
}
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
let details = {};
|
|
55
|
+
try { details = await response.json(); } catch { /* no usable local diagnostic */ }
|
|
56
|
+
throw Object.assign(new Error(`컴패니언 원본 분석 실패 (HTTP ${response.status}). ${details.message || "원본 파일과 로컬 처리기를 확인하세요."}`), { status: response.status, code: details.code || details.errorCode || details.error || "REVIEW_COMPANION_FAILED" });
|
|
57
|
+
}
|
|
58
|
+
const bodyText = await response.text();
|
|
59
|
+
if (Buffer.byteLength(bodyText) > 15 * 1024 * 1024) throw new Error("컴패니언 분석 응답이 15MiB를 초과했습니다. 전체 영상 대신 제한된 크기 청크가 필요합니다.");
|
|
60
|
+
return JSON.parse(bodyText);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkedSamples(samples, durationMs) {
|
|
64
|
+
if (!samples.length || samples.length > REVIEW_MAX_FRAMES) throw new Error("유효한 리뷰 샘플이 없습니다.");
|
|
65
|
+
return samples.map((sample, index) => {
|
|
66
|
+
const startMs = Math.round(Number(sample.startMs)), endMs = Math.round(Number(sample.endMs));
|
|
67
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs < 0 || endMs <= startMs || endMs > durationMs + 1) throw new Error("리뷰 샘플 구간이 원본 길이를 벗어납니다.");
|
|
68
|
+
return { ...sample, startMs, endMs, shotId: `R-${index + 1}`, representativeMs: Math.round(Math.max(startMs, Math.min(endMs, Number(sample.representativeMs) || (startMs + endMs) / 2))) };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeImage(sample) {
|
|
73
|
+
const mimeType = String(sample.mimeType || "image/jpeg").toLowerCase();
|
|
74
|
+
const imageBase64 = String(sample.imageBase64 || "");
|
|
75
|
+
if (!["image/jpeg", "image/png", "image/webp"].includes(mimeType) || !/^[A-Za-z0-9+/]+={0,2}$/.test(imageBase64) || imageBase64.length > 700_000) throw new Error("리뷰 샘플 이미지 형식 또는 크기가 잘못되었습니다.");
|
|
76
|
+
return { mimeType, imageBase64 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function uniqueIntervalSeconds(intervals) {
|
|
80
|
+
let total = 0, end = 0;
|
|
81
|
+
for (const item of [...intervals].sort((a, b) => a.startMs - b.startMs)) {
|
|
82
|
+
total += Math.max(0, item.endMs - Math.max(end, item.startMs));
|
|
83
|
+
end = Math.max(end, item.endMs);
|
|
84
|
+
}
|
|
85
|
+
return Math.round(total) / 1000;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function registerReviewTools(server, config, api, { wrap = handler => handler } = {}) {
|
|
89
|
+
const wrapReview = handler => wrap(async (args, extra) => {
|
|
90
|
+
try { return await handler(args, extra); }
|
|
91
|
+
catch (error) {
|
|
92
|
+
return { ...result({ error: error.errorCode || error.code || "REVIEW_OPERATION_FAILED", message: String(error?.message || error),
|
|
93
|
+
operationId: error.operationId || "", accountCostEvents: error.accountCostEvents || [], acceptedTaskCosts: error.acceptedTaskCosts || [], costUsd: error.costUsd || 0,
|
|
94
|
+
usage: error.usage || null, partialProviderUsage: error.partialProviderUsage === true,
|
|
95
|
+
warning: "자동 재시도하지 않았습니다. 이미 접수된 합성과 비용 원장을 확인한 뒤 재실행 여부를 결정하세요." }), isError: true };
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
server.tool("analyze_review_sources",
|
|
99
|
+
"영화·드라마 전체 영상+오디오를 Gemini 3.8로 분석하고 서사와 원본 근거를 재검증합니다. 기본 full은 모든 구간을 최대300초 MP4로 압축하여 클라우드에 전송하므로 사용자 업로드·과금 동의 후 allowCloudVideoUpload:true 필수. 첫 호출은 무료 원본 검사/범위 준비만, 이후 resumePath로 기본1단계씩 계속하세요. 완료된 청크는 재과금하지 않으며 실패 재시도는 명시 승인 필요. 대표 프레임 점검만 원하면 analysisScope:'sample'을 명시하세요(전체 이해가 아님).",
|
|
100
|
+
{ sources: z.array(sourceInput).min(1).max(5).optional(), analysisScope: z.enum(["full", "sample"]).optional(),
|
|
101
|
+
allowCloudVideoUpload: z.boolean().optional(), resumePath: z.string().optional(), workTitle: z.string().max(200).optional(),
|
|
102
|
+
maxSteps: z.number().int().min(1).max(4).optional(), retryFailedStage: z.boolean().optional(),
|
|
103
|
+
maxFrames: z.number().int().min(3).max(REVIEW_MAX_FRAMES).optional() },
|
|
104
|
+
wrapReview(async (args, extra) => {
|
|
105
|
+
if (args.analysisScope !== "sample") return result(await runFullReviewAnalysis({ config, api, args, companionJson,
|
|
106
|
+
onProgress: extra?._meta?.progressToken !== undefined && extra?.sendNotification ? value => extra.sendNotification({ method: "notifications/progress", params: {
|
|
107
|
+
progressToken: extra._meta.progressToken, progress: value.progress.completedChunks + value.progress.completedVerifications + (["verifying", "finalizing", "complete"].includes(value.progress.stage) ? 1 : 0),
|
|
108
|
+
total: value.progress.totalChunks + 1 + (value.progress.totalVerifications || value.progress.totalChunks),
|
|
109
|
+
message: `${value.progress.stage}: ${value.progress.completedChunks}/${value.progress.totalChunks} 청크, 검증 ${value.progress.completedVerifications}. 체크포인트 ${value.resumePath}`,
|
|
110
|
+
} }) : undefined,
|
|
111
|
+
}));
|
|
112
|
+
if (args.resumePath) throw new Error("전체 분석 resumePath를 sample 점검으로 바꿀 수 없습니다.");
|
|
113
|
+
const { sources: inputs, maxFrames = 12 } = args;
|
|
114
|
+
if (!inputs?.length) throw new Error("샘플 점검할 sources가 필요합니다.");
|
|
115
|
+
const sources = [], preDetectedShots = {}, videos = [], samplesById = new Map(), coverage = [];
|
|
116
|
+
// Validate every local file before any provider call.
|
|
117
|
+
const fingerprints = inputs.map(source => source.kind === "local" ? inspectReviewLocalFile(source.videoPath) : null);
|
|
118
|
+
for (const [index, input] of inputs.entries()) {
|
|
119
|
+
const id = `review-${randomUUID().slice(0, 8)}`;
|
|
120
|
+
const source = { id, kind: input.kind, title: input.title || `리뷰 원본 ${index + 1}` };
|
|
121
|
+
let samples;
|
|
122
|
+
if (input.kind === "local") {
|
|
123
|
+
source.localPath = fingerprints[index].path;
|
|
124
|
+
source.fingerprint = fingerprints[index];
|
|
125
|
+
const inspected = await companionJson(config, "/api/moviepy/inspect", { sources: [{ id, path: source.localPath, kind: "video" }] });
|
|
126
|
+
const info = inspected.sources?.find(item => item.id === id);
|
|
127
|
+
if (info?.ok !== true || !(info.durationSec > 0) || info.kind !== "video") throw new Error("로컬 원본의 실제 영상 길이를 확인하지 못했습니다.");
|
|
128
|
+
source.durationSec = Number(info.durationSec);
|
|
129
|
+
const durationMs = source.durationSec * 1000;
|
|
130
|
+
samples = checkedSamples(input.segments?.length ? input.segments : Array.from({ length: maxFrames }, (_, i) => {
|
|
131
|
+
const center = durationMs * (i + 0.5) / maxFrames;
|
|
132
|
+
return { startMs: Math.max(0, center - 3000), endMs: Math.min(durationMs, center + 3000) };
|
|
133
|
+
}), durationMs);
|
|
134
|
+
const frames = await companionJson(config, "/api/frames", { videoPath: source.localPath, timestampsMs: samples.map(item => item.representativeMs), width: 480, quality: 5 });
|
|
135
|
+
if (!Array.isArray(frames.frames) || frames.frames.length !== samples.length) throw new Error("필요한 리뷰 프레임을 모두 추출하지 못했습니다.");
|
|
136
|
+
samples = samples.map((sample, i) => ({ ...sample, ...safeImage(frames.frames[i]) }));
|
|
137
|
+
} else {
|
|
138
|
+
source.youtubeUrl = canonicalYoutubeWatchUrl(input.videoId);
|
|
139
|
+
source.videoId = new URL(source.youtubeUrl).searchParams.get("v");
|
|
140
|
+
const shots = await companionJson(config, "/api/shots", { url: source.youtubeUrl, maxShots: maxFrames, minGapMs: 1400, sceneThreshold: 0.22, width: 480, quality: 5 });
|
|
141
|
+
if (!Array.isArray(shots.shots) || !shots.shots.length) throw new Error("YouTube 원본에서 리뷰 구간을 찾지 못했습니다.");
|
|
142
|
+
source.durationSec = Number(shots.durationMs || Math.max(...shots.shots.map(item => Number(item.endMs) || 0))) / 1000;
|
|
143
|
+
if (input.segments?.length) {
|
|
144
|
+
samples = checkedSamples(input.segments, source.durationSec * 1000);
|
|
145
|
+
const frames = await companionJson(config, "/api/frames", { url: source.youtubeUrl, timestampsMs: samples.map(item => item.representativeMs), width: 480, quality: 5 });
|
|
146
|
+
if (frames.frames?.length !== samples.length) throw new Error("YouTube 리뷰 구간 프레임이 누락되었습니다.");
|
|
147
|
+
samples = samples.map((sample, i) => ({ ...sample, ...safeImage(frames.frames[i]) }));
|
|
148
|
+
} else samples = checkedSamples(shots.shots, source.durationSec * 1000).map(sample => ({ ...sample, ...safeImage(sample) }));
|
|
149
|
+
}
|
|
150
|
+
const transcriptSegments = (input.transcriptSegments || []).map(item => ({ startMs: item.startMs, endMs: item.endMs, text: item.text }));
|
|
151
|
+
if (transcriptSegments.some(item => item.startMs < 0 || item.endMs <= item.startMs || item.endMs > source.durationSec * 1000 + 1)) throw new Error("제공된 대본 구간이 원본 길이를 벗어납니다.");
|
|
152
|
+
samples = samples.map(sample => ({ ...sample, dialogueText: sample.dialogueText || transcriptSegments
|
|
153
|
+
.filter(item => item.startMs >= sample.startMs && item.endMs <= sample.endMs)
|
|
154
|
+
.sort((a, b) => a.startMs - b.startMs).map(item => item.text).join(" ").slice(0, 3000) }));
|
|
155
|
+
coverage.push({ reviewSourceId: id, durationSec: source.durationSec, representativeFrameCount: samples.length,
|
|
156
|
+
sampledWindowSeconds: uniqueIntervalSeconds(samples), transcriptSeconds: uniqueIntervalSeconds(transcriptSegments),
|
|
157
|
+
explicitWindows: Boolean(input.segments?.length), completeSourceObserved: false });
|
|
158
|
+
source.videoId ||= "";
|
|
159
|
+
sources.push(source);
|
|
160
|
+
samplesById.set(id, samples);
|
|
161
|
+
preDetectedShots[id] = samples.map(sample => ({ shotId: sample.shotId, startMs: sample.startMs, endMs: sample.endMs, representativeMs: sample.representativeMs, ...safeImage(sample) }));
|
|
162
|
+
for (const sample of samples) if (sample.dialogueText) transcriptSegments.push({ startMs: sample.startMs, endMs: sample.endMs, text: sample.dialogueText });
|
|
163
|
+
videos.push({ id, title: source.title, durationSeconds: source.durationSec, frameSamples: [], transcriptSegments,
|
|
164
|
+
description: samples.map(sample => sample.note || "").filter(Boolean).join(" / ").slice(0, 500),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
validateReviewSources(sources);
|
|
168
|
+
const body = { videos, preDetectedShots };
|
|
169
|
+
assertReviewBudget(body);
|
|
170
|
+
const analysis = await api.request("/api/tracker/gemini/video-analysis", { body, method: "POST", timeoutMs: 240_000 });
|
|
171
|
+
return processReviewResponse(analysis, () => {
|
|
172
|
+
const sourceCatalog = [];
|
|
173
|
+
for (const source of sources) {
|
|
174
|
+
const analyzed = analysis.videos?.find(video => video.id === source.id);
|
|
175
|
+
for (const sample of samplesById.get(source.id)) {
|
|
176
|
+
const scene = analyzed?.sceneCatalog?.find(item => item.shotId === sample.shotId || (Number(item.startMs) === sample.startMs && Number(item.endMs) === sample.endMs));
|
|
177
|
+
// The legacy analyzer fills shortLabel with a timestamp even when no
|
|
178
|
+
// observation was produced. A time label is not narrative evidence.
|
|
179
|
+
const description = [String(scene?.visualSummary || "").trim().slice(0, 800), sample.note?.trim() ? `제공된 구간 메모: ${sample.note.trim().slice(0, 350)}` : ""].filter(Boolean).join(" / ");
|
|
180
|
+
if (!description) continue;
|
|
181
|
+
sourceCatalog.push({ sourceId: `${source.id}:${sample.shotId}`, reviewSourceId: source.id, videoId: source.videoId,
|
|
182
|
+
startMs: sample.startMs, endMs: sample.endMs, description: description.slice(0, 1200),
|
|
183
|
+
dialogueText: sample.dialogueText || "", spoilerLevel: sample.spoilerLevel || "unknown",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!sourceCatalog.length) throw new Error("확인 가능한 리뷰 장면 설명을 얻지 못했습니다. 주요 구간과 메모를 제공하세요.");
|
|
188
|
+
const warnings = [COVERAGE_WARNING, "스포일러 등급은 사용자가 지정한 구간만 확인됨으로 취급합니다. unknown 구간은 none/limited 기획에서 제외됩니다.",
|
|
189
|
+
...coverage.map(item => `원본 ${item.reviewSourceId}: ${item.durationSec}초 중 대표 프레임 ${item.representativeFrameCount}장, 선택 구간 합계(중복 제외) ${item.sampledWindowSeconds}초, 제공 대본 ${item.transcriptSeconds}초. 구간 전체를 관찰했다는 뜻은 아닙니다.${item.explicitWindows ? "" : " 로컬 기본 샘플은 원본당 12개 × 6초(총 72초) 수준이므로 긴 리뷰에는 핵심 장면 구간·대본·메모를 추가하세요."}`)];
|
|
190
|
+
const reviewSourcePath = savePayloadHandle(config.stateDir, { kind: "aimakeall-review-sources", version: 1, analysisScope: "sample", sources, sourceCatalog, coverage, warnings }, "review-sources").filePath;
|
|
191
|
+
return result({ analysisScope: "sample", completeSourceObserved: false, reviewSourcePath, sources: reviewPublicSources(sources), sourceCatalog, coverage, warnings, requiresHumanReview: true,
|
|
192
|
+
usage: analysis.usage, accountCostEvents: analysis.accountCostEvents || [], costUsd: analysis.costUsd });
|
|
193
|
+
});
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
server.tool("review_analysis_status", "전체 원작 분석의 최신 진행률·청크 완료·원본 검증·누적 비용을 조회합니다. 유료 API를 호출하지 않습니다. 오래된 resumePath도 최신 체크포인트를 찾아 반환합니다.",
|
|
197
|
+
{ resumePath: z.string() }, wrapReview(async ({ resumePath }) => result(fullReviewStatus(config, resumePath))));
|
|
198
|
+
|
|
199
|
+
server.tool("plan_review_video",
|
|
200
|
+
"검토한 영화·드라마 원본 카탈로그로 60~900초 리뷰를 기획합니다. 원작 사실·해석·가상해석을 구분하며 spoilerPolicy를 지킵니다. reviewSourcePath는 analyze_review_sources 결과. 영화 전체를 봤다고 가정하지 않으며 기획에 분석 과금이 발생할 수 있습니다. 반환 productionPath를 보이스 추천·리뷰 편집에 전달하세요.",
|
|
201
|
+
{ reviewSourcePath: z.string(), workType: z.enum(["film", "drama"]), workTitle: z.string().min(1).max(200), episode: z.string().max(100).optional(),
|
|
202
|
+
targetDurationSec: z.number().min(60).max(900), spoilerPolicy: z.enum(["none", "limited", "full"]),
|
|
203
|
+
deliveryStyle: z.enum(["casual-social", "standard", "news-briefing"]).optional(), reviewAngle: z.string().max(2000).optional(),
|
|
204
|
+
provider: z.enum(["auto", ...TTS_PROVIDERS]).optional(), voiceId: z.string().optional(),
|
|
205
|
+
allowPartialSourceReview: z.boolean().optional().describe("명시 sample 점검 자료만으로 부분 구간 리뷰를 만드는 경우에만 true. 원작 전체 리뷰로 표현하지 않음"),
|
|
206
|
+
sourceIds: z.array(z.string()).min(1).max(500).optional().describe("이번 편집에 사용할 장면 sourceId 후보만 명시 선택. 전편 줄거리·전사는 그대로 보존"),
|
|
207
|
+
}, wrapReview(async ({ reviewSourcePath, provider, voiceId, allowPartialSourceReview, sourceIds, ...input }) => {
|
|
208
|
+
const handle = readReviewHandle(config.stateDir, reviewSourcePath);
|
|
209
|
+
validateReviewSources(handle.sources);
|
|
210
|
+
if (handle.analysisScope !== "full" && allowPartialSourceReview !== true) throw new Error("원작 전체 리뷰에는 full 분석 및 원본 재검증이 완료된 핸들이 필요합니다. 샘플만으로 전체를 이해했다고 가정하지 않습니다. 부분 구간 리뷰라면 allowPartialSourceReview:true를 명시하세요.");
|
|
211
|
+
if (handle.analysisScope === "full" && handle.originalVerification?.complete !== true) throw new Error("전체 원본 재검증이 완료되지 않아 리뷰 기획을 진행할 수 없습니다.");
|
|
212
|
+
if (voiceId && (!provider || provider === "auto")) throw new Error("명시한 보이스 ID의 공급자도 함께 지정하세요.");
|
|
213
|
+
const selection = selectReviewPlanningCatalog(handle, sourceIds);
|
|
214
|
+
const plan = await api.request("/api/tracker/review/plan", { body: { ...input, deliveryStyle: input.deliveryStyle || "casual-social", sourceCatalog: selection.sourceCatalog,
|
|
215
|
+
...(handle.analysisScope === "full" ? { analysisScope: "full", globalStory: handle.globalStory, fullTranscriptSegments: handle.fullTranscriptSegments, originalVerification: handle.originalVerification, sourceCoverage: handle.coverage } : { analysisScope: "sample" }),
|
|
216
|
+
}, method: "POST", timeoutMs: 600_000 });
|
|
217
|
+
return processReviewResponse(plan, () => {
|
|
218
|
+
if (!plan?.reviewProfile || !Array.isArray(plan.version?.rows) || !plan.version.rows.length) throw new Error("리뷰 기획 응답에 프로필 또는 편집 행이 없습니다.");
|
|
219
|
+
const productionPath = saveProductionContext(config.stateDir, { reviewSourcePath, reviewProfile: plan.reviewProfile, topic: input.workTitle,
|
|
220
|
+
productionProfile: plan.productionProfile, reviewVersion: plan.version, sourceCatalog: selection.sourceCatalog, catalogSelection: selection.catalogSelection,
|
|
221
|
+
...(handle.analysisScope === "full" ? { globalStory: handle.globalStory, fullTranscriptSegments: handle.fullTranscriptSegments, originalVerification: handle.originalVerification, analysisScope: "full" } : { analysisScope: "sample" }),
|
|
222
|
+
narrationScript: plan.version.rows.filter(row => row.mode === "N").map(row => row.audioContent || "").join("\n"),
|
|
223
|
+
warnings: [...handle.warnings, ...(plan.warnings || [])],
|
|
224
|
+
...(provider && provider !== "auto" ? { voice: { provider, ...(voiceId ? { voiceId } : {}) } } : {}),
|
|
225
|
+
});
|
|
226
|
+
return result({ ...plan, productionPath, catalogSelection: selection.catalogSelection, warnings: [...handle.warnings, ...(plan.warnings || [])], requiresHumanReview: true });
|
|
227
|
+
});
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
server.tool("prepare_review_timeline",
|
|
231
|
+
"영화·드라마 리뷰 기획 핸들에서 원본 컷·실측 TTS·자막·자동 라이브러리 편집을 준비합니다. N행은 TTS 과금이 있으므로 사용자에게 비용을 알리고 실행하세요. 실패한 합성을 자동 재시도하거나 다른 공급자로 바꾸지 않습니다. 로컬 원본 경로는 서버에 보내지 않으며 반환 payloadPath를 render_start에 전달하세요.",
|
|
232
|
+
{ productionPath: z.string(), provider: z.enum(["auto", ...TTS_PROVIDERS]).optional(), voiceId: z.string().optional(),
|
|
233
|
+
aspectRatio: z.enum(["16:9", "9:16", "1:1"]).optional(), autoCompose: z.boolean().optional(),
|
|
234
|
+
decorationRationale: z.string().max(1500).optional(),
|
|
235
|
+
titleStylePresetId: z.string().optional(), subtitleStylePresetId: z.string().optional(), titleStyle: composerTextStyle.optional(), subtitleStyle: composerTextStyle.optional(),
|
|
236
|
+
overlays: z.array(composerOverlay).max(40).optional(),
|
|
237
|
+
}, wrapReview(async ({ productionPath, provider, voiceId, ...options }) => {
|
|
238
|
+
const context = readProductionContext(config.stateDir, productionPath);
|
|
239
|
+
if (!context?.reviewProfile || !context.reviewVersion || !context.reviewSourcePath) throw new Error("plan_review_video가 반환한 리뷰 제작 핸들이 필요합니다.");
|
|
240
|
+
const sourcesHandle = readReviewHandle(config.stateDir, context.reviewSourcePath);
|
|
241
|
+
validateReviewSources(sourcesHandle.sources);
|
|
242
|
+
const hasNarration = context.reviewVersion.rows.some(row => row.mode === "N" && row.audioContent?.trim());
|
|
243
|
+
const explicitVoice = String(voiceId || "").trim();
|
|
244
|
+
const knownVoice = context.voice?.voiceId || context.voice?.id;
|
|
245
|
+
if (explicitVoice && (!provider || provider === "auto") && !(context.voice?.provider && explicitVoice === knownVoice)) throw new Error("명시한 보이스 ID의 공급자도 함께 지정하세요. 기존 제작 핸들에 저장된 동일 보이스만 공급자 생략이 가능합니다.");
|
|
246
|
+
const selection = hasNarration ? await resolveTtsSelection(api, config, { provider: explicitVoice && (!provider || provider === "auto") ? context.voice.provider : provider, context }) : { provider: "typecast", executionTarget: "server" };
|
|
247
|
+
if (!explicitVoice && knownVoice && selection.provider !== context.voice.provider) throw new Error("공급자를 변경하려면 해당 공급자의 보이스 ID를 명시하세요.");
|
|
248
|
+
const selectedVoice = explicitVoice || context.voice?.voiceId || context.voice?.id || "";
|
|
249
|
+
if (hasNarration && !selectedVoice && context.reviewProfile.deliveryStyle === "casual-social") throw new Error("먼저 recommend_voice에 productionPath를 전달하여 리뷰형 보이스를 선택하거나 voiceId를 명시하세요.");
|
|
250
|
+
let preSynthesizedNarrations;
|
|
251
|
+
if (hasNarration && selection.executionTarget === "companion") {
|
|
252
|
+
const narrations = {};
|
|
253
|
+
for (const [index, row] of context.reviewVersion.rows.entries()) {
|
|
254
|
+
if (row.mode !== "N" || !row.audioContent?.trim()) continue;
|
|
255
|
+
const audio = await requestTtsBinary(api, config, selection, buildTtsRequestBody(selection.provider, { text: row.audioContent, voice: selectedVoice, productionProfile: context.productionProfile }));
|
|
256
|
+
const durationSec = audio.buffer.toString("ascii", 0, 4) === "RIFF" ? wavDurationSec(audio.buffer) : mp3DurationSec(audio.buffer);
|
|
257
|
+
if (!(durationSec > 0)) throw new Error("리뷰 내레이션 길이를 측정하지 못했습니다. 자동 재합성하지 않습니다.");
|
|
258
|
+
narrations[index] = { dataUrl: `data:${audio.contentType};base64,${audio.buffer.toString("base64")}`, durationSec,
|
|
259
|
+
charCount: Array.from(row.audioContent).length, provider: selection.provider, voiceId: selectedVoice };
|
|
260
|
+
}
|
|
261
|
+
preSynthesizedNarrations = { narrations, attempted: Object.keys(narrations).length, succeeded: Object.keys(narrations).length, lastError: "" };
|
|
262
|
+
}
|
|
263
|
+
const body = { ...options, aspectRatio: options.aspectRatio || "16:9", autoCompose: options.autoCompose ?? true,
|
|
264
|
+
reviewProfile: context.reviewProfile, productionProfile: context.productionProfile, version: context.reviewVersion,
|
|
265
|
+
sourceCatalog: selectReviewPlanningCatalog(sourcesHandle, context.sourceCatalog?.map(scene => scene.sourceId)).sourceCatalog, sources: reviewPublicSources(sourcesHandle.sources),
|
|
266
|
+
provider: selection.provider, voiceId: selectedVoice, costEventId: createUsageEventId("mcp-review-tts"),
|
|
267
|
+
...(preSynthesizedNarrations ? { preSynthesizedNarrations } : {}),
|
|
268
|
+
};
|
|
269
|
+
assertReviewBudget(body);
|
|
270
|
+
const prepared = await api.request("/api/tracker/review/prepare", { body, method: "POST", timeoutMs: 600_000 });
|
|
271
|
+
return processReviewResponse(prepared, () => {
|
|
272
|
+
if (!prepared?.payload) throw new Error("리뷰 렌더 타임라인을 준비하지 못했습니다.");
|
|
273
|
+
const { payload, warnings } = bindReviewRenderSources(config.stateDir, prepared.payload, context.reviewSourcePath);
|
|
274
|
+
payload.productionContext = { productionProfile: context.productionProfile, reviewProfile: context.reviewProfile,
|
|
275
|
+
decorationRationale: options.decorationRationale || payload.compositionReport?.autoComposition?.omissions?.join(" / ") || (options.autoCompose === false ? "사용자가 자동 소재·애니메이션 편집을 명시적으로 비활성화했습니다. 추가 장식 없이 원작 중심으로 편집합니다." : "") };
|
|
276
|
+
const handle = savePayloadHandle(config.stateDir, payload, "review");
|
|
277
|
+
const nextPath = saveProductionContext(config.stateDir, { ...context, reviewPayloadPath: handle.filePath, voice: { provider: selection.provider, voiceId: selectedVoice } });
|
|
278
|
+
return result({ payloadPath: handle.filePath, productionPath: nextPath, compositionReport: payload.compositionReport,
|
|
279
|
+
totalDurationMs: prepared.totalDurationMs, costUsd: prepared.costUsd ?? 0,
|
|
280
|
+
accountCostEvents: prepared.accountCostEvents || [], acceptedTaskCosts: prepared.acceptedTaskCosts || [], operationId: prepared.operationId || "",
|
|
281
|
+
warnings: [...(context.warnings || []), ...warnings, ...(prepared.warnings || [])], requiresHumanReview: true,
|
|
282
|
+
next: "render_start({payloadPath})로 로컬 렌더 후 영상·음성·스포일러를 직접 확인하세요." });
|
|
283
|
+
});
|
|
284
|
+
}));
|
|
285
|
+
}
|
package/lib/tts-workflow.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const TTS_PROVIDERS = Object.freeze(["typecast", "elevenlabs", "supertone", "edge", "supertonic2"]);
|
|
2
2
|
|
|
3
|
-
export async function resolveTtsSelection(api, config, { provider = "auto", context = null } = {}) {
|
|
3
|
+
export async function resolveTtsSelection(api, config, { provider = "auto", context = null, validateCredentials = true } = {}) {
|
|
4
4
|
provider = provider === "auto" || !provider ? context?.voice?.provider || "auto" : provider;
|
|
5
5
|
if (provider === "edge-tts") provider = "edge";
|
|
6
6
|
if (provider !== "auto" && !TTS_PROVIDERS.includes(provider)) throw new Error("지원하지 않는 TTS provider입니다.");
|
|
7
7
|
// Explicit paid providers and saved castings must not silently change voice.
|
|
8
8
|
if (provider !== "auto" && provider !== "edge") return { provider, executionTarget: "server" };
|
|
9
|
-
const availability = await api.request(
|
|
9
|
+
const availability = await api.request(`/api/tracker/tts/providers${validateCredentials ? "?verify=1" : ""}`, { method: "GET", timeoutMs: validateCredentials ? 60_000 : 15_000 });
|
|
10
10
|
if (!Array.isArray(availability?.providers)) throw new Error("TTS 공급자 설정 응답이 없습니다. 자동 선택을 중단합니다.");
|
|
11
11
|
const providers = availability.providers;
|
|
12
12
|
const available = (id) => providers.some((entry) => entry.id === id && entry.available === true);
|
|
@@ -27,9 +27,9 @@ export async function resolveTtsSelection(api, config, { provider = "auto", cont
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function buildTtsRequestBody(provider, args = {}) {
|
|
30
|
-
const { productionProfile, text, voice = "", speed, costEventId, modelId, stability, style, similarityBoost, speakerBoost } = args;
|
|
30
|
+
const { productionProfile, text, voice = "", speed, costEventId, modelId, stability, style, similarityBoost, speakerBoost, fallbackVoices, allowProviderFallback } = args;
|
|
31
31
|
const resolvedSpeed = speed ?? (productionProfile?.deliveryStyle === "casual-social" ? 1.1 : undefined);
|
|
32
|
-
const common = { productionProfile, text, voice, voiceId: voice, speed: resolvedSpeed, costEventId, fileName: "mcp-narration", modelId };
|
|
32
|
+
const common = { productionProfile, text, voice, voiceId: voice, speed: resolvedSpeed, costEventId, fileName: "mcp-narration", modelId, fallbackVoices, allowProviderFallback };
|
|
33
33
|
if (provider === "typecast") return { ...common, language: "kor", emotion: "neutral", smartEmotion: true };
|
|
34
34
|
if (provider === "elevenlabs") return { ...common, languageCode: "ko", stability, style, similarityBoost, speakerBoost };
|
|
35
35
|
if (provider === "edge") {
|
|
@@ -41,7 +41,21 @@ export function buildTtsRequestBody(provider, args = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export async function requestTtsBinary(api, config, selection, body) {
|
|
44
|
-
if (selection.executionTarget !== "companion")
|
|
44
|
+
if (selection.executionTarget !== "companion") {
|
|
45
|
+
const result = await api.requestBinary(`/api/tracker/tts/${selection.provider}`, { body, method: "POST", timeoutMs: selection.provider === "supertonic2" ? 600_000 : 180_000 });
|
|
46
|
+
const actual = result.headers?.get?.("x-aimakeall-tts-provider");
|
|
47
|
+
const provider = TTS_PROVIDERS.includes(actual) ? actual : selection.provider;
|
|
48
|
+
const eventId = result.headers?.get?.("x-aio-cost-event-id");
|
|
49
|
+
const accountCostEvents = eventId ? [{ accountCostEventId: eventId,
|
|
50
|
+
createdAt: Number(result.headers.get("x-aio-cost-created-at")) || undefined,
|
|
51
|
+
costUsd: Number(result.headers.get("x-aio-cost-usd")) || 0,
|
|
52
|
+
model: result.headers.get("x-aio-cost-model") || "",
|
|
53
|
+
accountCostRecorded: result.headers.get("x-aio-cost-recorded") === "1",
|
|
54
|
+
accountCostSuppressedByReset: result.headers.get("x-aio-cost-suppressed-by-reset") === "1" }] : [];
|
|
55
|
+
return { ...result, provider, voiceId: result.headers?.get?.("x-aimakeall-tts-voice-id") || body.voiceId || "",
|
|
56
|
+
primaryProvider: selection.provider, providerFallback: provider !== selection.provider,
|
|
57
|
+
accountCostEvents, hasAccountCostEvents: true };
|
|
58
|
+
}
|
|
45
59
|
const voiceId = body.voiceId || selection.localVoices?.find((voice) => voice.voiceId === "ko-KR-SunHiNeural")?.voiceId || selection.localVoices?.find((voice) => String(voice.locale || "").startsWith("ko"))?.voiceId;
|
|
46
60
|
if (!voiceId) throw new Error("사용 가능한 한국어 Edge 보이스를 찾지 못했습니다. 보이스를 직접 지정하세요.");
|
|
47
61
|
const response = await fetch(`${config.companionUrl}/api/edge-tts/synthesize`, { body: JSON.stringify({ ...body, voiceId }), headers: { "Content-Type": "application/json" }, method: "POST", signal: AbortSignal.timeout(180_000) });
|