aimakeall-mcp 0.14.1 → 0.14.3
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 +8 -0
- package/lib/api-client.mjs +21 -4
- package/lib/cloud-tools.mjs +35 -5
- package/lib/config.mjs +1 -1
- package/lib/durable-speech.mjs +94 -0
- package/lib/generation-jobs.mjs +15 -0
- package/lib/review-full-analysis.mjs +184 -21
- package/lib/review-native-job-request.mjs +329 -0
- package/lib/review-story-job-request.mjs +131 -0
- package/lib/review-tools.mjs +4 -4
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -5,6 +5,14 @@ Claude Code·Codex 같은 MCP 클라이언트에서 자연어로 AImakeAll 영
|
|
|
5
5
|
- **클라우드 툴**: 기획 → 씬 이미지/영상 생성 → TTS/음악 → 타임라인 스티치 → YouTube 퍼블리시 (PAT 인증, aimakeall.com API)
|
|
6
6
|
- **로컬 툴**: mp4 렌더 — **항상 이 PC의 AImakeAll 컴패니언(127.0.0.1:9876)에서 수행**됩니다. 서버는 렌더하지 않습니다.
|
|
7
7
|
|
|
8
|
+
## 0.14.3 리뷰 분석 업데이트
|
|
9
|
+
|
|
10
|
+
전편 분석의 서버 실행 방식 조회, 계정·서버별 체크포인트 고정, 알려진 작업의 읽기 전용
|
|
11
|
+
복구를 추가했습니다. 공유 큐 실행은 서버가 명시적으로 제공할 때만 사용합니다.
|
|
12
|
+
기존 단일 서버에서는 명시된 동기 실행 방식을 유지하며, 요청 실패 후 유료 작업을
|
|
13
|
+
자동으로 다시 생성하지 않습니다. 이는 운영 PostgreSQL·자동 확장 활성화를 의미하지 않습니다.
|
|
14
|
+
실행 중인 제작은 먼저 완료한 뒤 MCP를 재연결하세요. 설치용 부트스트랩 버전은 그대로입니다.
|
|
15
|
+
|
|
8
16
|
## 설정
|
|
9
17
|
|
|
10
18
|
macOS·Windows에서 **Node.js 20 이상**이 필요합니다.
|
package/lib/api-client.mjs
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { MCP_PROXY_VERSION } from "./config.mjs";
|
|
2
|
+
import { attachReviewStoryJobFailureMetadata, REVIEW_STORY_JOBS } from "./review-story-job-request.mjs";
|
|
3
|
+
import { attachReviewNativeJobFailureMetadata } from "./review-native-job-request.mjs";
|
|
2
4
|
|
|
3
5
|
// aimakeall.com API 클라이언트 — PAT(Bearer) 인증 + 프록시 버전 핸드셰이크.
|
|
4
6
|
// 서버 오류 코드를 사용자 행동 가능한 한국어 안내로 매핑한다.
|
|
5
7
|
|
|
6
8
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
9
|
+
const clientAuthorities = new WeakMap();
|
|
10
|
+
// Authenticated transports must not silently use the token captured by an old
|
|
11
|
+
// API client after its caller's config changed. Non-HTTP test adapters return
|
|
12
|
+
// null; the actual client identity remains private and exposes no credential.
|
|
13
|
+
export function matchesApiClientAuthority(client, { apiBase, pat } = {}) {
|
|
14
|
+
const binding = clientAuthorities.get(client);
|
|
15
|
+
return binding ? typeof pat === "string" && pat.length > 0 && binding.pat === pat
|
|
16
|
+
&& binding.apiBase === String(apiBase || "").replace(/\/$/u, "") : null;
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
export class AimakeallApiError extends Error {
|
|
9
20
|
constructor(message, { errorCode = "", retryAfterSeconds = 0, status = 0, accountCostEvents, acceptedTaskCosts, operationId, costUsd, usage, partialProviderUsage, fallbackMetadata } = {}) {
|
|
@@ -42,7 +53,7 @@ function describeApiFailure(status, payload) {
|
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export function createApiClient({ apiBase, pat }) {
|
|
45
|
-
async function request(pathName, { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS, rawBody, headers = {} } = {}) {
|
|
56
|
+
async function request(pathName, { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS, rawBody, headers = {}, signal, cache } = {}) {
|
|
46
57
|
const requestHeaders = {
|
|
47
58
|
Authorization: `Bearer ${pat}`,
|
|
48
59
|
"X-AImakeAll-MCP-Version": MCP_PROXY_VERSION,
|
|
@@ -62,7 +73,8 @@ export function createApiClient({ apiBase, pat }) {
|
|
|
62
73
|
...(rawBody !== undefined && typeof rawBody?.pipe !== "function" && rawBody?.[Symbol.asyncIterator] ? { duplex: "half" } : {}),
|
|
63
74
|
headers: requestHeaders,
|
|
64
75
|
method,
|
|
65
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
76
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
77
|
+
...(cache === undefined ? {} : { cache }),
|
|
66
78
|
});
|
|
67
79
|
|
|
68
80
|
const text = await response.text();
|
|
@@ -74,7 +86,7 @@ export function createApiClient({ apiBase, pat }) {
|
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
if (!response.ok) {
|
|
77
|
-
|
|
89
|
+
const error = new AimakeallApiError(describeApiFailure(response.status, payload), {
|
|
78
90
|
errorCode: String(payload?.code || payload?.error || ""),
|
|
79
91
|
retryAfterSeconds: Number(payload?.retryAfterSeconds) || Number(response.headers.get("retry-after")) || 0,
|
|
80
92
|
status: response.status,
|
|
@@ -83,6 +95,9 @@ export function createApiClient({ apiBase, pat }) {
|
|
|
83
95
|
usage: payload?.usage, partialProviderUsage: payload?.partialProviderUsage,
|
|
84
96
|
fallbackMetadata: payload,
|
|
85
97
|
});
|
|
98
|
+
if (pathName === REVIEW_STORY_JOBS) attachReviewStoryJobFailureMetadata(error, payload);
|
|
99
|
+
if (["/api/tracker/review/chunk-jobs", "/api/tracker/review/evidence-jobs"].includes(pathName)) attachReviewNativeJobFailureMetadata(error, payload);
|
|
100
|
+
throw error;
|
|
86
101
|
}
|
|
87
102
|
return payload;
|
|
88
103
|
}
|
|
@@ -229,5 +244,7 @@ export function createApiClient({ apiBase, pat }) {
|
|
|
229
244
|
return { events, result };
|
|
230
245
|
}
|
|
231
246
|
|
|
232
|
-
|
|
247
|
+
const client = { request, requestBinary, requestSse, requestStreamUpload };
|
|
248
|
+
clientAuthorities.set(client, { apiBase: String(apiBase || "").replace(/\/$/u, ""), pat });
|
|
249
|
+
return client;
|
|
233
250
|
}
|
package/lib/cloud-tools.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
import { audioDurationSecFromFile } from "./audio-duration.mjs";
|
|
24
24
|
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
25
25
|
import { createUsageEventId } from "./usage-event.mjs";
|
|
26
|
-
import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob } from "./generation-jobs.mjs";
|
|
26
|
+
import { fetchGenerationJob, GENERATION_JOB_PATH, submitGenerationJob, requestGenerationJobAction } from "./generation-jobs.mjs";
|
|
27
|
+
import { durableSpeechToolSchema, submitDurableSpeech, durableSpeechResult } from "./durable-speech.mjs";
|
|
27
28
|
import { loudnessDbfsFromWav, silenceRatioFromWav } from "./wav-dsp.mjs";
|
|
28
29
|
import { trimWavToSeconds } from "./wav-trim.mjs";
|
|
29
30
|
import { registerComposerTools } from "./composer-tools.mjs";
|
|
@@ -203,6 +204,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
203
204
|
registerComposerTools(server, config, api);
|
|
204
205
|
registerReviewTools(server, config, api, { wrap: handler => wrapCloudHandler(config, handler) });
|
|
205
206
|
async function completedGenerationResult(job, includePreview) {
|
|
207
|
+
if (job.kind === "tts") return jsonResult(durableSpeechResult(job, config));
|
|
206
208
|
const payload = job.result || {};
|
|
207
209
|
const resultBody = { generationJobId: job.id, status: job.status, kind: job.kind,
|
|
208
210
|
model: payload.model, taskId: payload.taskId, providerTasks: job.providerTasks };
|
|
@@ -242,21 +244,49 @@ export function registerCloudTools(server, config, api) {
|
|
|
242
244
|
|
|
243
245
|
server.tool(
|
|
244
246
|
"get_generation_job",
|
|
245
|
-
"
|
|
247
|
+
"이미지·영상·start_speech_generation의 generationJobId 상태·결과를 조회합니다. running이면 같은 ID를 다시 조회하세요. TTS 완료는 서버 소유 오디오 링크를 반환하며 다운로드·재합성하지 않습니다. MCP 재시작 뒤에도 같은 ID를 사용하세요.",
|
|
246
248
|
{ generationJobId: z.string(), includePreview: z.boolean().optional().describe("이미지/대표 프레임 미리보기 포함. 미지정 시 생성 요청의 returnImage/returnFrames 설정 유지") },
|
|
247
249
|
wrapCloudHandler(config, async ({ generationJobId, includePreview }) => {
|
|
248
250
|
const job = await fetchGenerationJob(api, generationJobId);
|
|
249
251
|
if (job.status === "completed") return completedGenerationResult(job, includePreview ?? job.includePreview ?? true);
|
|
250
252
|
return textResult(JSON.stringify({ generationJobId: job.id, kind: job.kind, status: job.status,
|
|
251
|
-
providerTasks: job.providerTasks, error: job.error,
|
|
252
|
-
|
|
253
|
+
providerTasks: job.providerTasks, error: job.error, workflow: job.workflow,
|
|
254
|
+
...(job.status === "cancelled" ? { result: job.result } : {}),
|
|
255
|
+
guidance: job.status === "running" ? "생성 중입니다. 5~10초 후 이 ID를 다시 조회하세요. 새로 생성하지 마세요."
|
|
256
|
+
: ["cancel_requested", "cancelled"].includes(job.status) ? "취소 의도가 저장되어 있습니다. 공급자 중단·환불을 의미하지 않습니다. 이미 접수된 작업과 비용 원장을 확인하세요."
|
|
257
|
+
: job.workflow?.canResume ? "resume_generation_job으로 이미 접수된 작업의 상태만 복구할 수 있습니다. 새 유료 생성을 제출하지 마세요."
|
|
258
|
+
: "비용 원장과 providerTasks를 먼저 확인하세요. 실패·중단 작업을 자동 재생성하지 마세요." }, null, 2),
|
|
253
259
|
{ isError: job.status === "failed" || job.status === "interrupted" });
|
|
254
260
|
}),
|
|
255
261
|
);
|
|
256
262
|
|
|
263
|
+
server.tool(
|
|
264
|
+
"start_speech_generation",
|
|
265
|
+
"지정한 ElevenLabs/Typecast/Supertone 한 곳에 비동기 내레이션 작업을 접수합니다. 공유 실행 저장소가 필요합니다. requestId를 유지하고 get_generation_job으로 조회하세요. 대체 공급자·자동 재합성·기존 바이너리 TTS로 전환하지 않으며, 완료 오디오는 서버 소유 링크로 반환됩니다.",
|
|
266
|
+
durableSpeechToolSchema,
|
|
267
|
+
wrapCloudHandler(config, async input => {
|
|
268
|
+
const result = await submitDurableSpeech(api, input);
|
|
269
|
+
return textResult(JSON.stringify(result, null, 2), { isError: ["unavailable", "rejected"].includes(result.status) });
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
server.tool(
|
|
274
|
+
"resume_generation_job",
|
|
275
|
+
"내 계정의 기존 생성 작업에서 접수된 공급자 작업 상태·결과를 복구합니다. 원래 유료 생성은 다시 제출하지 않습니다. get_generation_job의 workflow.canResume이 true일 때 사용하세요.",
|
|
276
|
+
{ generationJobId: z.string() },
|
|
277
|
+
wrapCloudHandler(config, async ({ generationJobId }) => jsonResult(await requestGenerationJobAction(api, generationJobId, "resume"))),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
server.tool(
|
|
281
|
+
"cancel_generation_job",
|
|
282
|
+
"내 계정의 기존 생성 작업에 취소 의도를 저장하고 가능한 로컬 대기를 중단합니다. 공급자 작업 중단·환불을 보장하지 않으며 접수된 작업과 비용 기록은 유지합니다.",
|
|
283
|
+
{ generationJobId: z.string() },
|
|
284
|
+
wrapCloudHandler(config, async ({ generationJobId }) => jsonResult(await requestGenerationJobAction(api, generationJobId, "cancel"))),
|
|
285
|
+
);
|
|
286
|
+
|
|
257
287
|
server.tool(
|
|
258
288
|
"list_generation_jobs",
|
|
259
|
-
"내 계정의 최근
|
|
289
|
+
"내 계정의 최근 이미지·영상·음성 생성 작업 ID와 상태를 조회합니다. 연결이 끊기거나 MCP를 재시작했을 때 결과를 복구하고 중복 생성을 피하는 데 사용하세요.",
|
|
260
290
|
{},
|
|
261
291
|
wrapCloudHandler(config, async () => jsonResult(await api.request(GENERATION_JOB_PATH, { timeoutMs: 30_000 }))),
|
|
262
292
|
);
|
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.14.
|
|
5
|
+
export const MCP_PROXY_VERSION = "0.14.3";
|
|
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";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_API_BASE } from "./config.mjs";
|
|
3
|
+
import { GENERATION_JOB_PATH } from "./generation-jobs.mjs";
|
|
4
|
+
|
|
5
|
+
const ID = /^[a-zA-Z0-9_-]{16,80}$/u, PROVIDERS = ["elevenlabs", "typecast", "supertone"];
|
|
6
|
+
const MODELS = Object.freeze({ elevenlabs: "elevenlabs-multilingual-v2", typecast: "typecast-narration", supertone: "supertone-narration" });
|
|
7
|
+
const common = ["voice", "voiceId", "modelId", "fileName", "speed"];
|
|
8
|
+
const perProvider = Object.freeze({ elevenlabs: ["languageCode", "similarityBoost", "speakerBoost", "stability", "style"],
|
|
9
|
+
typecast: ["language", "emotion", "pitch", "smartEmotion", "volume"], supertone: ["language", "lang", "style"] });
|
|
10
|
+
const invalid = () => new Error("Invalid durable speech request or result.");
|
|
11
|
+
const short = max => z.string().max(max);
|
|
12
|
+
const optionsSchema = z.object({ voice: short(320).optional(), voiceId: short(160).optional(), modelId: short(100).optional(), fileName: short(240).optional(),
|
|
13
|
+
languageCode: short(20).optional(), language: short(20).optional(), lang: short(20).optional(), emotion: short(160).optional(),
|
|
14
|
+
speed: z.number().min(0.5).max(2).optional(), similarityBoost: z.number().min(0).max(1).optional(), stability: z.number().min(0).max(1).optional(),
|
|
15
|
+
style: z.union([z.number().min(0).max(1), short(160)]).optional(), speakerBoost: z.boolean().optional(), smartEmotion: z.boolean().optional(),
|
|
16
|
+
pitch: z.number().int().min(-12).max(12).optional(), volume: z.number().int().min(0).max(200).optional(),
|
|
17
|
+
}).strict();
|
|
18
|
+
export const durableSpeechToolSchema = Object.freeze({
|
|
19
|
+
requestId: z.string().regex(ID).describe("새 작업의 고유 ID. 응답이 끊겨도 이 ID를 유지하고 먼저 상태를 조회하세요."),
|
|
20
|
+
provider: z.enum(PROVIDERS).describe("지정 공급자만 사용합니다. 자동 선택·대체 공급자 재합성 없음"),
|
|
21
|
+
text: z.string().min(1).max(10000).describe("전체 내레이션. 최대 5,000자, Supertone은 300자"),
|
|
22
|
+
options: optionsSchema.optional().describe("해당 공급자의 보이스·모델·언어·속도 옵션. 비용 이벤트/실행 상태는 전달하지 마세요."),
|
|
23
|
+
});
|
|
24
|
+
function capture(value) {
|
|
25
|
+
if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw invalid();
|
|
26
|
+
const props = Object.getOwnPropertyDescriptors(value);
|
|
27
|
+
if (Reflect.ownKeys(props).some(key => !["requestId", "provider", "text", "options"].includes(key) || !Object.hasOwn(props[key], "value"))) throw invalid();
|
|
28
|
+
const options = props.options?.value;
|
|
29
|
+
if (options !== undefined && (!options || typeof options !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
|
|
30
|
+
|| Reflect.ownKeys(Object.getOwnPropertyDescriptors(options)).some(key => typeof key !== "string" || !Object.hasOwn(Object.getOwnPropertyDescriptor(options, key), "value")))) throw invalid();
|
|
31
|
+
const parsed = z.object(durableSpeechToolSchema).strict().parse(value), { provider } = parsed;
|
|
32
|
+
if (!parsed.text.isWellFormed() || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(parsed.text)
|
|
33
|
+
|| !parsed.text.trim() || Array.from(parsed.text).length > (provider === "supertone" ? 300 : 5000)) throw invalid();
|
|
34
|
+
const rawOptions = parsed.options || {};
|
|
35
|
+
if (Object.keys(rawOptions).some(key => ![...common, ...perProvider[provider]].includes(key))) throw invalid();
|
|
36
|
+
for (const [key, value] of Object.entries(rawOptions)) {
|
|
37
|
+
if (typeof value === "string" && (!value.isWellFormed() || /[\u0000-\u001f\u007f]/u.test(value))) throw invalid();
|
|
38
|
+
if (["voiceId", "modelId"].includes(key) && value && !/^[A-Za-z0-9_.:-]+$/u.test(value)) throw invalid();
|
|
39
|
+
}
|
|
40
|
+
if (provider === "elevenlabs" && (rawOptions.speed !== undefined && (rawOptions.speed < 0.7 || rawOptions.speed > 1.2)
|
|
41
|
+
|| rawOptions.style !== undefined && typeof rawOptions.style !== "number")
|
|
42
|
+
|| provider === "supertone" && rawOptions.style !== undefined && typeof rawOptions.style !== "string") throw invalid();
|
|
43
|
+
return { requestId: parsed.requestId, input: { provider, text: parsed.text.trim(), ...rawOptions, allowProviderFallback: false } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One JSON submit only. Neither errors nor a missing shared backend may invoke
|
|
47
|
+
* the legacy binary tool, switch provider, or create a replacement request ID. */
|
|
48
|
+
export async function submitDurableSpeech(api, value) {
|
|
49
|
+
const { requestId, input } = capture(value), base = { generationJobId: requestId, kind: "tts", paidReplayAllowed: false, nextTool: "get_generation_job" };
|
|
50
|
+
try {
|
|
51
|
+
const response = await api.request(GENERATION_JOB_PATH, { method: "POST", timeoutMs: 30000, body: { id: requestId, kind: "tts", input, includePreview: false } });
|
|
52
|
+
if (response?.job?.id !== requestId || response.job.kind !== "tts" || !["pending", "running", "completed", "failed", "interrupted", "cancel_requested", "cancelled"].includes(response.job.status)) throw invalid();
|
|
53
|
+
return { ...base, status: response.job.status, existing: response.existing === true,
|
|
54
|
+
guidance: "이 ID를 get_generation_job으로 조회하세요. 완료 전 새 TTS 작업이나 바이너리 합성 도구로 다시 요청하지 마세요." };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const code = typeof error?.errorCode === "string" && /^[A-Z][A-Z0-9_]{0,99}$/u.test(error.errorCode) ? error.errorCode : "SPEECH_SUBMISSION_UNCONFIRMED";
|
|
57
|
+
if (code === "MACHINE_TTS_DURABLE_WORKLOAD_REQUIRED") return { ...base, status: "unavailable", code,
|
|
58
|
+
guidance: "서버의 공유 TTS 실행 저장소가 준비되지 않았습니다. 자동 재시도나 기존 바이너리 합성으로 전환하지 마세요." };
|
|
59
|
+
return { ...base, status: Number(error?.status) >= 400 && Number(error?.status) < 500 ? "rejected" : "unknown", code,
|
|
60
|
+
guidance: "이 작업 ID로 먼저 상태를 조회하세요. 접수 결과가 불명확하면 새 ID 제출·공급자 교체·바이너리 재합성을 하지 마세요." };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pure URL projection only: no fetch and no PAT-bearing request to an asset.
|
|
65
|
+
* Only the configured server's canonical protected filename and its asset_sig
|
|
66
|
+
* may leave this helper; provider URLs, credentials and unrelated query data do
|
|
67
|
+
* not become clickable output. */
|
|
68
|
+
export function durableSpeechResult(job, { apiBase = DEFAULT_API_BASE } = {}) {
|
|
69
|
+
const payload = job?.result;
|
|
70
|
+
if (!ID.test(job?.id || "") || job.kind !== "tts" || job.status !== "completed" || !PROVIDERS.includes(payload?.provider)
|
|
71
|
+
|| payload.primaryProvider !== payload.provider || !/^user-[a-f0-9]{24}-[A-Za-z0-9._-]+$/u.test(payload.filename || "")
|
|
72
|
+
|| payload.filename.includes("..") || payload.filename.length > 512 || !["audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4"].includes(payload.contentType)) throw invalid();
|
|
73
|
+
const base = new URL(apiBase), rawUrl = payload.assetUrl;
|
|
74
|
+
if (!["https:", "http:"].includes(base.protocol) || base.username || base.password || base.search || base.hash
|
|
75
|
+
|| typeof rawUrl !== "string" || rawUrl.length > 8192 || /[\s\\\u0000-\u001f\u007f]/u.test(rawUrl) || rawUrl.startsWith("//")) throw invalid();
|
|
76
|
+
const url = new URL(rawUrl, base.origin);
|
|
77
|
+
if (url.origin !== base.origin || url.username || url.password || url.hash || url.pathname !== `/api/tracker/extension/assets/${encodeURIComponent(payload.filename)}`
|
|
78
|
+
|| ![url.href, `${url.pathname}${url.search}`].includes(rawUrl)
|
|
79
|
+
|| [...url.searchParams.keys()].some(key => key !== "asset_sig") || url.searchParams.getAll("asset_sig").length > 1
|
|
80
|
+
|| url.searchParams.has("asset_sig") && !/^[A-Za-z0-9_-]{43}$/u.test(url.searchParams.get("asset_sig"))) throw invalid();
|
|
81
|
+
const cost = payload.accountCostEvents?.[0];
|
|
82
|
+
if (payload.hasAccountCostEvents !== true || !Array.isArray(payload.accountCostEvents) || payload.accountCostEvents.length !== 1
|
|
83
|
+
|| !cost || cost.provider !== payload.provider || cost.model !== MODELS[payload.provider] || !/^tts-job-v1:[a-f0-9]{64}$/u.test(cost.accountCostEventId || "")
|
|
84
|
+
|| payload.accountCostEventId !== cost.accountCostEventId || payload.accountCostRecorded !== cost.accountCostRecorded
|
|
85
|
+
|| payload.accountCostSuppressedByReset !== cost.accountCostSuppressedByReset
|
|
86
|
+
|| typeof cost.costUsd !== "number" || !Number.isFinite(cost.costUsd) || cost.costUsd < 0 || cost.costUsd > 10000
|
|
87
|
+
|| !Number.isSafeInteger(cost.createdAt) || cost.createdAt < 1
|
|
88
|
+
|| !(cost.accountCostRecorded === true && cost.accountCostSuppressedByReset !== true || cost.accountCostRecorded === false && cost.accountCostSuppressedByReset === true)) throw invalid();
|
|
89
|
+
const receipt = { accountCostEventId: cost.accountCostEventId, model: cost.model, provider: cost.provider, createdAt: cost.createdAt,
|
|
90
|
+
costUsd: cost.costUsd, accountCostRecorded: cost.accountCostRecorded, ...(cost.accountCostSuppressedByReset === true ? { accountCostSuppressedByReset: true } : {}) };
|
|
91
|
+
return { generationJobId: job.id, status: "completed", kind: "tts", provider: payload.provider, filename: payload.filename, contentType: payload.contentType,
|
|
92
|
+
serverUrl: base.origin, assetPath: `${url.pathname}${url.search}`, assetUrl: url.href, ...receipt, hasAccountCostEvents: true, accountCostEvents: [receipt],
|
|
93
|
+
guidance: "오디오가 서버의 계정 소유 자산으로 저장되었습니다. 이 링크를 사용하세요. 이 도구는 오디오를 다운로드하거나 PAT를 자산 요청에 전달하지 않습니다." };
|
|
94
|
+
}
|
package/lib/generation-jobs.mjs
CHANGED
|
@@ -37,3 +37,18 @@ export async function fetchGenerationJob(api, generationJobId) {
|
|
|
37
37
|
if (!response?.job || response.job.id !== id) throw new Error("생성 작업 상태 응답이 올바르지 않습니다.");
|
|
38
38
|
return response.job;
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
// Both actions operate on the authenticated owner's existing job only. Resume
|
|
42
|
+
// observes accepted provider receipts; it never resubmits the original request.
|
|
43
|
+
export async function requestGenerationJobAction(api, generationJobId, action) {
|
|
44
|
+
const id = assertGenerationJobId(generationJobId);
|
|
45
|
+
if (!["resume", "cancel"].includes(action)) throw new Error("지원하지 않는 생성 작업 동작입니다.");
|
|
46
|
+
const response = await api.request(`${GENERATION_JOB_PATH}/${encodeURIComponent(id)}/${action}`, { method: "POST", timeoutMs: 30_000 });
|
|
47
|
+
if (!response?.job || response.job.id !== id || response.job.workflow?.paidReplayAllowed !== false) throw new Error("생성 작업 상태 응답이 올바르지 않습니다.");
|
|
48
|
+
return { generationJobId: id, kind: response.job.kind, status: response.job.status,
|
|
49
|
+
workflow: response.job.workflow, providerTasks: response.job.providerTasks, existing: response.existing === true,
|
|
50
|
+
nextTool: "get_generation_job",
|
|
51
|
+
guidance: action === "resume"
|
|
52
|
+
? "접수된 공급자 작업의 상태·결과만 복구합니다. 유료 생성을 다시 제출하지 않습니다. get_generation_job으로 이 ID를 조회하세요."
|
|
53
|
+
: "취소 의도를 저장했습니다. 공급자 작업 중단·비용 환불은 보장하지 않습니다. 이미 접수된 작업과 비용 기록은 유지되므로 get_generation_job으로 확인하세요." };
|
|
54
|
+
}
|
|
@@ -4,6 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import { savePayloadHandle } from "./media-store.mjs";
|
|
5
5
|
import { canonicalYoutubeWatchUrl } from "./payload-guard.mjs";
|
|
6
6
|
import { inspectReviewLocalFile, readReviewHandle, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
|
|
7
|
+
import { createReviewStoryJobClient, REVIEW_STORY_TRANSPORT } from "./review-story-job-request.mjs";
|
|
8
|
+
import { createReviewNativeJobClient, REVIEW_NATIVE_TRANSPORT, unwrapReviewNativeJobResult } from "./review-native-job-request.mjs";
|
|
9
|
+
import { DEFAULT_API_BASE } from "./config.mjs";
|
|
10
|
+
import { matchesApiClientAuthority } from "./api-client.mjs";
|
|
7
11
|
|
|
8
12
|
const KIND = "aimakeall-review-full-analysis";
|
|
9
13
|
const MAX_CHUNK_BYTES = 10 * 1024 * 1024;
|
|
@@ -68,14 +72,64 @@ function lockAnalysis(config, analysisId) {
|
|
|
68
72
|
function receipt(value, stage, chunkId = "") {
|
|
69
73
|
return { stage, chunkId, ...Object.fromEntries(RECEIPT_KEYS.filter(key => value?.[key] !== undefined).map(key => [key, value[key]])) };
|
|
70
74
|
}
|
|
75
|
+
function upsertStoryReceipt(state, value, operationId) {
|
|
76
|
+
upsertStageReceipt(state, value, operationId, "synthesizing");
|
|
77
|
+
}
|
|
78
|
+
function upsertStageReceipt(state, value, operationId, stage, chunkId = "") {
|
|
79
|
+
const item = { ...receipt(value, stage, chunkId), clientOperationId: operationId };
|
|
80
|
+
const index = state.receipts.findIndex(row => row.clientOperationId === operationId);
|
|
81
|
+
if (index < 0) state.receipts.push(item);
|
|
82
|
+
else {
|
|
83
|
+
const previous = state.receipts[index];
|
|
84
|
+
const unique = field => [...new Map([...(previous[field] || []), ...(item[field] || [])]
|
|
85
|
+
.map(event => [event.accountCostEventId || event.eventId || event.id || JSON.stringify(event), event])).values()];
|
|
86
|
+
state.receipts[index] = { ...previous, ...item, usage: item.usage || previous.usage,
|
|
87
|
+
operationId: item.operationId || previous.operationId, costUsd: Math.max(Number(previous.costUsd) || 0, Number(item.costUsd) || 0),
|
|
88
|
+
accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts") };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function authorityBinding(config) {
|
|
92
|
+
const scope = new URL(config.apiBase || DEFAULT_API_BASE).href.replace(/\/$/u, "");
|
|
93
|
+
return { apiScope: scope, authorityFingerprint: createHash("sha256").update(JSON.stringify([scope, config.pat || ""])).digest("hex") };
|
|
94
|
+
}
|
|
95
|
+
function assertCheckpointAuthority(config, state) {
|
|
96
|
+
const current = authorityBinding(config);
|
|
97
|
+
if (!state.authorityBinding && (state.inFlight || state.pendingStageResult || state.receipts?.length || Object.keys(state.analyses || {}).length)) {
|
|
98
|
+
throw fail("REVIEW_CHECKPOINT_AUTHORITY_MISSING", "이전 분석 기록에 서버·계정 연결 증명이 없습니다. 기존 파일의 로컬 확인만 가능하며 현재 PAT로 결과를 적용하거나 유료 요청을 다시 보내지 않습니다.");
|
|
99
|
+
}
|
|
100
|
+
if (state.authorityBinding && (state.authorityBinding.apiScope !== current.apiScope || state.authorityBinding.authorityFingerprint !== current.authorityFingerprint)) {
|
|
101
|
+
throw fail("REVIEW_CHECKPOINT_AUTHORITY_CHANGED", "분석을 시작한 서버와 PAT로 기존 작업을 확인해 주세요. 계정이 변경된 상태에서는 업로드·유료 요청·결과 조회를 하지 않습니다.");
|
|
102
|
+
}
|
|
103
|
+
state.authorityBinding ||= current;
|
|
104
|
+
}
|
|
105
|
+
function storyClient(config, api) {
|
|
106
|
+
return createReviewStoryJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
|
|
107
|
+
getAuthority: () => transportAuthority(config, api),
|
|
108
|
+
request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function nativeClient(config, api) {
|
|
112
|
+
return createReviewNativeJobClient({ apiScope: config.apiBase || DEFAULT_API_BASE,
|
|
113
|
+
getAuthority: () => transportAuthority(config, api),
|
|
114
|
+
request: (route, options) => api.request(route, { ...options, ...(options.body === undefined ? {} : { body: JSON.parse(options.body) }) }),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function transportAuthority(config, api) {
|
|
118
|
+
if (matchesApiClientAuthority(api, { apiBase: config.apiBase || DEFAULT_API_BASE, pat: config.pat }) === false) return null;
|
|
119
|
+
return config.pat ? JSON.stringify([config.apiBase || DEFAULT_API_BASE, config.pat]) : api;
|
|
120
|
+
}
|
|
121
|
+
function mediaDescriptor(media) {
|
|
122
|
+
const { videoBase64, ...chunk } = media.chunk;
|
|
123
|
+
return { source: media.source, chunk, ...(media.workTitle !== undefined ? { workTitle: media.workTitle } : {}) };
|
|
124
|
+
}
|
|
71
125
|
function safeStageResult(value) {
|
|
72
126
|
if (Array.isArray(value)) return value.map(safeStageResult);
|
|
73
127
|
if (!value || typeof value !== "object") return value;
|
|
74
|
-
return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|
|
|
128
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !/base64|dataUrl|localPath|sourceHandle|apiKey|^token$|accessToken|refreshToken|authorization|cookie/i.test(key)).map(([key, item]) => [key, safeStageResult(item)]));
|
|
75
129
|
}
|
|
76
130
|
function billing(state) {
|
|
77
131
|
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()];
|
|
132
|
+
const unique = (field) => [...new Map(costs.flatMap(item => item[field] || []).map(item => [item.accountCostEventId || item.eventId || item.id || JSON.stringify(item), item])).values()];
|
|
79
133
|
return { accountCostEvents: unique("accountCostEvents"), acceptedTaskCosts: unique("acceptedTaskCosts"),
|
|
80
134
|
costUsd: costs.reduce((sum, item) => sum + (Number(item.costUsd) || 0), 0), stageReceipts: costs };
|
|
81
135
|
}
|
|
@@ -94,12 +148,58 @@ function summary(state, resumePath) {
|
|
|
94
148
|
analyzedTimelineSec: analyzedMs / 1000, totalTimelineSec: state.sources.reduce((sum, item) => sum + item.durationSec, 0) },
|
|
95
149
|
sources: reviewPublicSources(state.sources), ...billing(state),
|
|
96
150
|
warnings: state.warnings || [], error: state.error || undefined,
|
|
151
|
+
...(state.inFlight?.transport === REVIEW_STORY_TRANSPORT ? { storyJob: { requestId: state.inFlight.operationId,
|
|
152
|
+
jobId: state.inFlight.jobId || null, apiScope: state.inFlight.apiScope, recovery: "read-only-known-job", paidReplayAllowed: false } } : {}),
|
|
153
|
+
...(state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT ? { nativeJob: { stage: state.inFlight.stage,
|
|
154
|
+
requestId: state.inFlight.operationId, jobId: state.inFlight.jobId || null, status: state.inFlight.jobStatus || "unknown",
|
|
155
|
+
apiScope: state.inFlight.apiScope, mediaAttemptId: state.inFlight.mediaAttempt?.attemptId || null,
|
|
156
|
+
fileState: state.inFlight.mediaReference ? "ready" : state.inFlight.mediaAttempt ? "reconciliation-required" : "not-uploaded",
|
|
157
|
+
recovery: state.inFlight.jobId ? "read-only-known-job" : state.inFlight.submissionAttempted ? "submission-unknown-no-replay" : "reconcile-upload",
|
|
158
|
+
paidReplayAllowed: false, automaticUploadRetryAllowed: false } } : {}),
|
|
159
|
+
mediaFiles: { ready: Object.keys(state.mediaReferences || {}).length, retainedForVerification: true },
|
|
160
|
+
reviewRequired: state.reviewRequired === true,
|
|
97
161
|
failedStageResultPath: state.failedStageResultPath || undefined,
|
|
98
162
|
requiresHumanReview: true, next: state.stage === "complete" ? "plan_review_video({reviewSourcePath,...})" : state.stage === "blocked" ? "실패 원인·이미 접수된 비용을 확인하세요. 자동 재시도하지 않습니다." : "analyze_review_sources({resumePath,allowCloudVideoUpload:true})로 다음 1개 단계를 계속하세요.",
|
|
99
163
|
};
|
|
100
164
|
}
|
|
101
165
|
export function fullReviewStatus(config, resumePath) { const loaded = loadFullReviewAnalysis(config, resumePath); return summary(loaded.state, loaded.resumePath); }
|
|
102
166
|
|
|
167
|
+
export async function refreshFullReviewStatus(config, api, resumePath) {
|
|
168
|
+
const loaded = loadFullReviewAnalysis(config, resumePath);
|
|
169
|
+
const release = lockAnalysis(config, loaded.state.analysisId);
|
|
170
|
+
try {
|
|
171
|
+
let { state, resumePath: latestPath } = loadFullReviewAnalysis(config, resumePath);
|
|
172
|
+
assertCheckpointAuthority(config, state);
|
|
173
|
+
const operation = state.inFlight;
|
|
174
|
+
if (![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(operation?.transport)
|
|
175
|
+
|| operation.transport === REVIEW_STORY_TRANSPORT && !operation.jobId) return summary(state, latestPath);
|
|
176
|
+
try {
|
|
177
|
+
const job = operation.transport === REVIEW_STORY_TRANSPORT
|
|
178
|
+
? await storyClient(config, api).read(operation.jobId, { requestId: operation.operationId, apiScope: operation.apiScope })
|
|
179
|
+
: await nativeClient(config, api).read(operation.stage, { ...operation, requestId: operation.operationId,
|
|
180
|
+
onCheckpoint: async metadata => {
|
|
181
|
+
assertCheckpointAuthority(config, state); Object.assign(operation, metadata);
|
|
182
|
+
if (operation.mediaReference) (state.mediaReferences ||= {})[operation.chunkId] = operation.mediaReference;
|
|
183
|
+
latestPath = snapshot(config, state);
|
|
184
|
+
} });
|
|
185
|
+
assertCheckpointAuthority(config, state);
|
|
186
|
+
operation.jobStatus = job.status;
|
|
187
|
+
if (job.status === "completed") {
|
|
188
|
+
if (!job.result || typeof job.result !== "object" || Array.isArray(job.result)) throw fail("REVIEW_STORY_RESULT_MISSING", "저장된 줄거리 결과가 없습니다.");
|
|
189
|
+
const result = operation.transport === REVIEW_STORY_TRANSPORT ? job.result
|
|
190
|
+
: unwrapReviewNativeJobResult(operation.stage, job.result, operation.mediaReference);
|
|
191
|
+
upsertStageReceipt(state, result, operation.operationId, operation.stage, operation.chunkId);
|
|
192
|
+
state.pendingStageResult = safeStageResult(result);
|
|
193
|
+
}
|
|
194
|
+
latestPath = snapshot(config, state);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
state.error = { code: error.code || error.errorCode || "REVIEW_JOB_READ_FAILED", message: String(error.message || error) };
|
|
197
|
+
latestPath = snapshot(config, state);
|
|
198
|
+
}
|
|
199
|
+
return summary(state, latestPath);
|
|
200
|
+
} finally { release(); }
|
|
201
|
+
}
|
|
202
|
+
|
|
103
203
|
// This limits only the editing candidate list, never the full story/transcript
|
|
104
204
|
// evidence. All native analysis remains in the original immutable handle.
|
|
105
205
|
export function selectReviewPlanningCatalog(handle, requestedIds) {
|
|
@@ -188,7 +288,8 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
188
288
|
const health = await companionJson(config, "/health", undefined, 15000);
|
|
189
289
|
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
290
|
const state = { kind: KIND, version: 1, analysisId: randomUUID(), revision: 0, stage: "analyzing", workTitle: args.workTitle || "",
|
|
191
|
-
sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [],
|
|
291
|
+
sources: [], chunks: [], analyses: {}, verifications: {}, receipts: [], mediaReferences: {}, authorityBinding: authorityBinding(config),
|
|
292
|
+
warnings: ["전체 구간을 압축 영상+오디오로 분석하고 서사 주장을 실제 원본 구간으로 재검증합니다. 공개 권리와 최종 해석은 사람이 확인해야 합니다."], uploadConsent: true };
|
|
192
293
|
const fingerprints = args.sources.map(source => source.kind === "local" ? inspectReviewLocalFile(source.videoPath) : null);
|
|
193
294
|
for (const [index, input] of args.sources.entries()) {
|
|
194
295
|
const id = `review-${randomUUID().slice(0, 8)}`;
|
|
@@ -209,16 +310,27 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
209
310
|
let state, resumePath;
|
|
210
311
|
try {
|
|
211
312
|
({ state, resumePath } = loadFullReviewAnalysis(config, args.resumePath));
|
|
212
|
-
|
|
313
|
+
assertCheckpointAuthority(config, state);
|
|
213
314
|
if (state.stage === "complete") return summary(state, resumePath);
|
|
315
|
+
if (state.reviewRequired) return { ...summary(state, resumePath), requiresExplicitRetryApproval: false };
|
|
214
316
|
if (state.inFlight || state.stage === "blocked") {
|
|
317
|
+
if ([REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) || state.pendingStageResult) {
|
|
318
|
+
if (!state.inFlight || !["analyzing", "synthesizing", "verifying"].includes(state.inFlight.stage)
|
|
319
|
+
|| state.inFlight.transport === REVIEW_STORY_TRANSPORT && state.inFlight.stage !== "synthesizing") throw fail("REVIEW_JOB_STATE_INVALID", "저장된 작업 단계가 다릅니다.");
|
|
320
|
+
state.stage = state.inFlight.stage;
|
|
321
|
+
delete state.error;
|
|
322
|
+
} else {
|
|
215
323
|
if (args.retryFailedStage !== true) return { ...summary(state, resumePath), requiresExplicitRetryApproval: true };
|
|
216
324
|
state.stage = state.failedStage || state.inFlight?.stage || "analyzing";
|
|
217
325
|
delete state.inFlight; delete state.error;
|
|
326
|
+
}
|
|
218
327
|
resumePath = snapshot(config, state);
|
|
219
328
|
}
|
|
220
329
|
const refreshed = new Set();
|
|
221
330
|
const getMedia = async (chunk, expectedSha256) => {
|
|
331
|
+
// A known server job / saved result does not need the local source. Only
|
|
332
|
+
// a genuinely new media transfer or explicit legacy recheck reads it.
|
|
333
|
+
validateReviewSources(state.sources);
|
|
222
334
|
const index = state.sources.findIndex(source => source.id === chunk.sourceId);
|
|
223
335
|
try { return await videoChunk(config, companionJson, state.sources[index], chunk, expectedSha256); }
|
|
224
336
|
catch (error) {
|
|
@@ -232,41 +344,87 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
232
344
|
if (!onProgress) return;
|
|
233
345
|
try { await onProgress(summary(state, resumePath)); } catch { /* Progress delivery cannot replay/abort an accepted paid operation. */ }
|
|
234
346
|
};
|
|
235
|
-
const charge = async (stage, chunkId,
|
|
236
|
-
|
|
347
|
+
const charge = async (stage, chunkId, body, options = {}) => {
|
|
348
|
+
assertCheckpointAuthority(config, state);
|
|
349
|
+
const existing = state.inFlight;
|
|
350
|
+
if (existing && (existing.stage !== stage || existing.chunkId !== chunkId)) throw fail("REVIEW_JOB_STATE_INVALID", "체크포인트의 작업 구간이 다릅니다.");
|
|
351
|
+
if (state.pendingStageResult) return state.pendingStageResult;
|
|
352
|
+
state.inFlight = existing || { stage, chunkId, operationId: randomUUID(),
|
|
353
|
+
...(stage === "analyzing" ? { mediaDescriptor: mediaDescriptor(body), priorContext: body.priorContext || "" } : {}) }; state.failedStage = stage;
|
|
237
354
|
resumePath = snapshot(config, state);
|
|
238
355
|
await progress();
|
|
239
356
|
let response;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
357
|
+
const operationId = state.inFlight.operationId;
|
|
358
|
+
try {
|
|
359
|
+
response = stage === "synthesizing"
|
|
360
|
+
? await storyClient(config, api).run(body, {
|
|
361
|
+
requestId: operationId, ...(existing ? { transport: existing.transport, jobId: existing.jobId, apiScope: existing.apiScope } : {}),
|
|
362
|
+
onSubmissionAttempt: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
|
|
363
|
+
onJobAccepted: async metadata => { Object.assign(state.inFlight, metadata); resumePath = snapshot(config, state); },
|
|
364
|
+
})
|
|
365
|
+
: await nativeClient(config, api).run(stage, body, { ...state.inFlight, requestId: operationId,
|
|
366
|
+
// A resumed operation owns its exact reference and origin. Never
|
|
367
|
+
// repair a damaged/missing binding from the analysis-wide cache.
|
|
368
|
+
...(!existing && options.mediaReference ? { mediaReference: options.mediaReference,
|
|
369
|
+
apiScope: state.authorityBinding.apiScope } : {}),
|
|
370
|
+
getLegacyMedia: options.getLegacyMedia,
|
|
371
|
+
onCheckpoint: async metadata => {
|
|
372
|
+
assertCheckpointAuthority(config, state); Object.assign(state.inFlight, metadata);
|
|
373
|
+
if (state.inFlight.mediaReference) (state.mediaReferences ||= {})[chunkId] = state.inFlight.mediaReference;
|
|
374
|
+
resumePath = snapshot(config, state);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (stage === "synthesizing") upsertStoryReceipt(state, error, operationId);
|
|
380
|
+
else upsertStageReceipt(state, error, operationId, stage, chunkId);
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
assertCheckpointAuthority(config, state);
|
|
384
|
+
if (stage === "synthesizing") upsertStoryReceipt(state, response, operationId);
|
|
385
|
+
else upsertStageReceipt(state, response, operationId, stage, chunkId);
|
|
386
|
+
if (state.inFlight.transport) state.inFlight.jobStatus = "completed";
|
|
243
387
|
state.pendingStageResult = safeStageResult(response);
|
|
388
|
+
// Commit received output before validation/application can fail or MCP exits.
|
|
389
|
+
resumePath = snapshot(config, state);
|
|
244
390
|
return response;
|
|
245
391
|
};
|
|
246
392
|
for (let step = 0; step < (args.maxSteps ?? 1); step++) {
|
|
247
393
|
if (state.stage === "analyzing") {
|
|
248
394
|
const chunk = state.chunks.find(item => !state.analyses[item.chunkId]);
|
|
249
395
|
if (!chunk) { state.stage = "synthesizing"; resumePath = snapshot(config, state); step--; continue; }
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
396
|
+
let media;
|
|
397
|
+
if (state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT || state.pendingStageResult) {
|
|
398
|
+
media = state.inFlight.mediaDescriptor;
|
|
399
|
+
if (!media?.chunk || media.chunk.chunkId !== chunk.chunkId) throw fail("REVIEW_MEDIA_CHECKPOINT_INVALID", "저장된 미디어 구간 정보가 없습니다. 다시 인코딩하거나 업로드하지 않습니다.");
|
|
400
|
+
} else {
|
|
401
|
+
media = await getMedia(chunk); media.workTitle = state.workTitle;
|
|
402
|
+
const previous = Object.values(state.analyses).filter(item => item.sourceId === chunk.sourceId && item.startMs < chunk.startMs).sort((a, b) => b.startMs - a.startMs)[0];
|
|
403
|
+
if (previous) media.priorContext = JSON.stringify({ previousSummary: previous.summary, observedCharacters: previous.characters }).slice(0, 12000);
|
|
404
|
+
}
|
|
405
|
+
const response = await charge("analyzing", chunk.chunkId, state.inFlight?.transport === REVIEW_NATIVE_TRANSPORT
|
|
406
|
+
? state.inFlight.jobId ? null : { priorContext: state.inFlight.priorContext || "" } : media);
|
|
255
407
|
state.analyses[chunk.chunkId] = checkedAnalysis(response, chunk, media);
|
|
256
408
|
} else if (state.stage === "synthesizing") {
|
|
257
409
|
const body = { sources: reviewPublicSources(state.sources), chunks: Object.values(state.analyses), workTitle: state.workTitle };
|
|
258
410
|
textBudget(body);
|
|
259
|
-
state.storyResult = await charge("synthesizing", "",
|
|
411
|
+
state.storyResult = await charge("synthesizing", "", body);
|
|
260
412
|
state.verifyChunkIds = verificationGroups(state);
|
|
261
413
|
state.stage = "verifying";
|
|
262
414
|
} else if (state.stage === "verifying") {
|
|
263
415
|
const id = state.verifyChunkIds.find(chunkId => !state.verifications[chunkId]);
|
|
264
416
|
if (!id) { state.stage = "finalizing"; step--; continue; }
|
|
265
417
|
const chunk = state.chunks.find(item => item.chunkId === id), analyzed = state.analyses[id];
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
418
|
+
const response = await charge("verifying", id, state.inFlight?.jobId ? null
|
|
419
|
+
: { story: state.storyResult.story, chunks: Object.values(state.analyses), chunkId: id },
|
|
420
|
+
{ mediaReference: state.mediaReferences?.[id], getLegacyMedia: async () => ({ ...await getMedia(chunk, analyzed.sha256), workTitle: state.workTitle }) });
|
|
421
|
+
if (response.chunkId !== id || !Array.isArray(response.verdicts) || !response.verdicts.length
|
|
422
|
+
|| response.verdicts.some(item => !["supported", "contradicted", "insufficient"].includes(item.status))) throw fail("REVIEW_ORIGINAL_VERIFICATION_INVALID", "저장된 원본 재검증 결과가 불완전합니다. 다시 과금하지 않습니다.");
|
|
269
423
|
state.verifications[id] = response;
|
|
424
|
+
if (response.verdicts.some(item => item.status !== "supported")) {
|
|
425
|
+
state.reviewRequired = true;
|
|
426
|
+
throw fail("REVIEW_ORIGINAL_VERIFICATION_FAILED", "원본 재검증에서 모순되거나 근거가 부족한 주장이 발견되었습니다. 판정과 비용은 저장했습니다. 자동 재검증·전체 이해 완료·자동 기획으로 진행하지 않으며 사용자의 검토가 필요합니다.");
|
|
427
|
+
}
|
|
270
428
|
} else if (state.stage === "finalizing") {
|
|
271
429
|
const completed = { kind: "aimakeall-review-sources", version: 1, analysisScope: "full", analysisId: state.analysisId,
|
|
272
430
|
sources: state.sources, sourceCatalog: Object.values(state.analyses).flatMap(chunk => chunk.sceneCatalog), globalStory: state.storyResult.story,
|
|
@@ -290,13 +448,18 @@ export async function runFullReviewAnalysis({ config, api, args, companionJson,
|
|
|
290
448
|
return summary(state, resumePath);
|
|
291
449
|
} catch (error) {
|
|
292
450
|
if (!state) throw error;
|
|
451
|
+
// Never attach another account's saved costs/results to the caller or write
|
|
452
|
+
// through its journal after PAT/server changes (including mid-request).
|
|
453
|
+
assertCheckpointAuthority(config, state);
|
|
293
454
|
state.failedStage ||= state.stage; state.stage = "blocked";
|
|
294
455
|
state.error = { code: error.errorCode || error.code || "REVIEW_FULL_ANALYSIS_FAILED", message: String(error.message || error) };
|
|
295
|
-
if (state.pendingStageResult) {
|
|
456
|
+
if (state.pendingStageResult && state.inFlight?.transport !== REVIEW_STORY_TRANSPORT) {
|
|
296
457
|
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
|
-
|
|
458
|
+
// Keep the received result in the journal too: a restart must apply it,
|
|
459
|
+
// never repeat the paid request (including legacy successful responses).
|
|
298
460
|
}
|
|
299
461
|
resumePath = snapshot(config, state);
|
|
300
|
-
return { ...summary(state, resumePath), requiresExplicitRetryApproval:
|
|
462
|
+
return { ...summary(state, resumePath), requiresExplicitRetryApproval: !state.reviewRequired
|
|
463
|
+
&& ![REVIEW_STORY_TRANSPORT, REVIEW_NATIVE_TRANSPORT].includes(state.inFlight?.transport) };
|
|
301
464
|
} finally { release(); }
|
|
302
465
|
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Shared browser/MCP one-way native-media transport. A stored attempt is never
|
|
2
|
+
// permission to upload again; a submitted paid request is only ever read again.
|
|
3
|
+
export const REVIEW_NATIVE_TRANSPORT = "review-native-queue-v2";
|
|
4
|
+
export const REVIEW_NATIVE_CAPABILITY = "/api/tracker/review/native-execution";
|
|
5
|
+
export const REVIEW_NATIVE_ATTEMPTS = "/api/tracker/review/media-attempts";
|
|
6
|
+
export const REVIEW_NATIVE_CHUNK_JOBS = "/api/tracker/review/chunk-jobs";
|
|
7
|
+
export const REVIEW_NATIVE_EVIDENCE_JOBS = "/api/tracker/review/evidence-jobs";
|
|
8
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
9
|
+
const ASSET = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
10
|
+
const HASH = /^[a-f0-9]{64}$/u, JOB = /^epoch-v2-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
11
|
+
const MAX_VIDEO = 10 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
|
|
12
|
+
const encoder = new TextEncoder(), knownFailures = new WeakMap();
|
|
13
|
+
// Native ingress admits one request per owner. Serialize actual I/O (not whole
|
|
14
|
+
// queued jobs) across client instances. An adapter ignoring AbortSignal keeps
|
|
15
|
+
// its slot until it truly settles; a timed-out waiter never dispatches later.
|
|
16
|
+
const requestLanes = new Map();
|
|
17
|
+
function serializedRequest(scope, authority, check, callback) {
|
|
18
|
+
let owners = requestLanes.get(scope);
|
|
19
|
+
if (!owners) { owners = new Map(); requestLanes.set(scope, owners); }
|
|
20
|
+
const previous = owners.get(authority) || Promise.resolve();
|
|
21
|
+
const work = previous.then(() => { check(); return callback(); });
|
|
22
|
+
const settled = work.then(() => {}, () => {}).finally(() => {
|
|
23
|
+
if (owners.get(authority) === settled) owners.delete(authority);
|
|
24
|
+
if (!owners.size && requestLanes.get(scope) === owners) requestLanes.delete(scope);
|
|
25
|
+
});
|
|
26
|
+
owners.set(authority, settled); return work;
|
|
27
|
+
}
|
|
28
|
+
const stages = Object.freeze({ analyzing: { path: REVIEW_NATIVE_CHUNK_JOBS, type: "review:analyze-chunk", kind: "review-chunk", field: "chunkAnalysis", legacy: "analyze-chunk" },
|
|
29
|
+
verifying: { path: REVIEW_NATIVE_EVIDENCE_JOBS, type: "review:verify-evidence", kind: "review-evidence", field: "verification", legacy: "verify-evidence" } });
|
|
30
|
+
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
31
|
+
const failure = (code, message = "원본 분석 기록을 안전하게 확인하지 못했습니다. 자동으로 다시 생성하지 않습니다.") => Object.assign(new Error(message), { code, paidReplayAllowed: false, automaticUploadRetryAllowed: false });
|
|
32
|
+
function freeze(value) { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; }
|
|
33
|
+
function fields(value, allowed, required = []) {
|
|
34
|
+
if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
35
|
+
const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
|
|
36
|
+
if (keys.some(key => !allowed.includes(key) || !("value" in descriptors[key]) || !descriptors[key].enumerable)
|
|
37
|
+
|| required.some(key => !Object.hasOwn(descriptors, key))) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
38
|
+
return Object.fromEntries(keys.map(key => [key, descriptors[key].value]));
|
|
39
|
+
}
|
|
40
|
+
function capture(value, maximum = MAX_TEXT) {
|
|
41
|
+
let nodes = 0, bytes = 0; const ancestors = new Set();
|
|
42
|
+
const add = size => { if ((bytes += size) > maximum) throw failure("REVIEW_NATIVE_INPUT_LIMIT"); };
|
|
43
|
+
function copy(item, depth) {
|
|
44
|
+
if (++nodes > 400000 || depth > 24) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
45
|
+
if (item === null || typeof item === "boolean") { add(JSON.stringify(item).length); return item; }
|
|
46
|
+
if (typeof item === "number" && Number.isFinite(item)) { add(JSON.stringify(item).length); return item; }
|
|
47
|
+
if (typeof item === "string") {
|
|
48
|
+
if (item.length > maximum || !item.isWellFormed()) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
49
|
+
add(encoder.encode(JSON.stringify(item)).length); return item;
|
|
50
|
+
}
|
|
51
|
+
if (!item || typeof item !== "object" || ancestors.has(item)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
52
|
+
const array = Array.isArray(item), descriptors = Object.getOwnPropertyDescriptors(item), keys = Reflect.ownKeys(descriptors);
|
|
53
|
+
if (array ? Object.getPrototypeOf(item) !== Array.prototype : ![Object.prototype, null].includes(Object.getPrototypeOf(item))) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
54
|
+
if (keys.some(key => typeof key !== "string" || !("value" in descriptors[key]) || key.length > 128 || ["__proto__", "prototype", "constructor", "toJSON"].includes(key)
|
|
55
|
+
|| key !== "length" && !descriptors[key].enumerable)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
56
|
+
ancestors.add(item); add(2); let result;
|
|
57
|
+
if (array) {
|
|
58
|
+
if (item.length > 60000 || keys.length !== item.length + 1) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
59
|
+
result = Array.from({ length: item.length }, (_, index) => { if (!Object.hasOwn(descriptors, index)) throw failure("REVIEW_NATIVE_INPUT_INVALID"); if (index) add(1); return copy(descriptors[index].value, depth + 1); });
|
|
60
|
+
} else result = Object.fromEntries(keys.map((key, index) => { add(encoder.encode(JSON.stringify(key)).length + 1 + (index ? 1 : 0)); return [key, copy(descriptors[key].value, depth + 1)]; }));
|
|
61
|
+
ancestors.delete(item); return result;
|
|
62
|
+
}
|
|
63
|
+
return freeze(copy(value, 0));
|
|
64
|
+
}
|
|
65
|
+
function scopeOf(value) {
|
|
66
|
+
let parsed; try { parsed = new URL(value); } catch { throw failure("REVIEW_NATIVE_SCOPE_INVALID"); }
|
|
67
|
+
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) throw failure("REVIEW_NATIVE_SCOPE_INVALID");
|
|
68
|
+
return parsed.href.replace(/\/$/u, "");
|
|
69
|
+
}
|
|
70
|
+
const text = (value, maximum, empty = true) => {
|
|
71
|
+
if (typeof value !== "string" || value.length > maximum || !value.isWellFormed() || (!empty && !value.trim()) || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
function metadata(value) {
|
|
75
|
+
const keys = ["version", "sourceId", "chunkId", "startMs", "endMs", "sourceDurationMs", "hasAudio", "videoId", "workTitle", "mimeType", "actualDurationSec"];
|
|
76
|
+
const raw = fields(value, keys, keys.filter(key => key !== "actualDurationSec"));
|
|
77
|
+
if (raw.version !== 1 || raw.mimeType !== "video/mp4" || typeof raw.sourceId !== "string" || !/^[A-Za-z0-9_.:-]{1,90}$/u.test(raw.sourceId)
|
|
78
|
+
|| !Number.isSafeInteger(raw.sourceDurationMs) || raw.sourceDurationMs < 1 || raw.sourceDurationMs > 86400000
|
|
79
|
+
|| !Number.isSafeInteger(raw.startMs) || !Number.isSafeInteger(raw.endMs) || raw.startMs < 0 || raw.endMs <= raw.startMs || raw.endMs > raw.sourceDurationMs
|
|
80
|
+
|| raw.endMs - raw.startMs > 300000 || raw.chunkId !== `${raw.sourceId}:${raw.startMs}-${raw.endMs}` || typeof raw.hasAudio !== "boolean"
|
|
81
|
+
|| typeof raw.videoId !== "string" || raw.videoId !== "" && !/^[A-Za-z0-9_-]{11}$/u.test(raw.videoId)
|
|
82
|
+
|| text(raw.workTitle, 200) !== raw.workTitle.trim() || raw.actualDurationSec !== undefined && (!Number.isFinite(raw.actualDurationSec) || raw.actualDurationSec <= 0 || Math.abs(raw.actualDurationSec * 1000 - raw.endMs + raw.startMs) > 250)) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
83
|
+
return freeze(Object.fromEntries(keys.filter(key => Object.hasOwn(raw, key)).map(key => [key, raw[key]])));
|
|
84
|
+
}
|
|
85
|
+
function mediaIdentity(value, reference = false) {
|
|
86
|
+
const keys = reference ? ["assetId", "namespace", "metadata", "sha256", "byteLength", "inputHash", "expiresAt"] : ["attemptId", "metadata", "sha256", "byteLength", "inputHash"];
|
|
87
|
+
const raw = fields(value, keys, keys);
|
|
88
|
+
if (!HASH.test(raw.sha256 || "") || !HASH.test(raw.inputHash || "") || !Number.isSafeInteger(raw.byteLength) || raw.byteLength < 16 || raw.byteLength > MAX_VIDEO
|
|
89
|
+
|| reference && (!ASSET.test(raw.assetId || "") || !HASH.test(raw.namespace || "") || !Number.isSafeInteger(raw.expiresAt) || raw.expiresAt <= 0)
|
|
90
|
+
|| !reference && !UUID.test(raw.attemptId || "")) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
91
|
+
return freeze({ ...raw, metadata: metadata(raw.metadata) });
|
|
92
|
+
}
|
|
93
|
+
/** Compact allowlist suitable for IndexedDB/MCP checkpoint files. No tokens,
|
|
94
|
+
* paths, source handles, source URL, raw media or server-owned keys are allowed. */
|
|
95
|
+
export function captureReviewNativeCheckpoint(value) {
|
|
96
|
+
const raw = fields(value, ["transport", "apiScope", "mediaAttempt", "mediaReference", "submissionAttempted", "requestBinding", "jobId"]);
|
|
97
|
+
if (raw.transport !== undefined && raw.transport !== REVIEW_NATIVE_TRANSPORT || raw.submissionAttempted !== undefined && typeof raw.submissionAttempted !== "boolean"
|
|
98
|
+
|| raw.requestBinding !== undefined && !HASH.test(raw.requestBinding) || raw.jobId !== undefined && !JOB.test(raw.jobId)) throw failure("REVIEW_NATIVE_CHECKPOINT_INVALID");
|
|
99
|
+
return freeze({ ...raw, ...(raw.apiScope === undefined ? {} : { apiScope: scopeOf(raw.apiScope) }),
|
|
100
|
+
...(raw.mediaAttempt === undefined ? {} : { mediaAttempt: mediaIdentity(raw.mediaAttempt) }),
|
|
101
|
+
...(raw.mediaReference === undefined ? {} : { mediaReference: mediaIdentity(raw.mediaReference, true) }) });
|
|
102
|
+
}
|
|
103
|
+
const digest = async value => [...new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", typeof value === "string" ? encoder.encode(value) : value))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
104
|
+
async function checkIdentity(identity) {
|
|
105
|
+
if (await digest(JSON.stringify({ version: 1, metadata: identity.metadata, sha256: identity.sha256, byteLength: identity.byteLength })) !== identity.inputHash) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
106
|
+
}
|
|
107
|
+
function uploadShape(value) {
|
|
108
|
+
const raw = fields(value, ["source", "chunk", "workTitle", "priorContext"], ["source", "chunk"]);
|
|
109
|
+
const source = fields(raw.source, ["id", "videoId", "kind", "title", "durationSec", "hasAudio"], ["id", "durationSec"]);
|
|
110
|
+
const chunk = fields(raw.chunk, ["chunkId", "sourceId", "startMs", "endMs", "mimeType", "videoBase64", "sha256", "hasAudio", "actualDurationSec"], ["chunkId", "startMs", "endMs", "mimeType", "videoBase64", "sha256", "hasAudio"]);
|
|
111
|
+
if (!Number.isFinite(source.durationSec) || source.durationSec <= 0 || source.durationSec > 86400 || source.kind !== undefined && !["local", "youtube"].includes(source.kind)
|
|
112
|
+
|| chunk.sourceId !== undefined && chunk.sourceId !== source.id || source.hasAudio !== undefined && source.hasAudio !== chunk.hasAudio) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
113
|
+
if (source.title !== undefined) text(source.title, 200);
|
|
114
|
+
const meta = metadata({ version: 1, sourceId: source.id, chunkId: chunk.chunkId, startMs: chunk.startMs, endMs: chunk.endMs,
|
|
115
|
+
sourceDurationMs: Math.round(source.durationSec * 1000), hasAudio: chunk.hasAudio, videoId: (source.videoId ?? "").trim(),
|
|
116
|
+
workTitle: text(raw.workTitle ?? source.title ?? "", 200).trim(), mimeType: chunk.mimeType,
|
|
117
|
+
...(chunk.actualDurationSec === undefined ? {} : { actualDurationSec: chunk.actualDurationSec }) });
|
|
118
|
+
if (typeof chunk.videoBase64 !== "string" || chunk.videoBase64.length > 4 * Math.ceil(MAX_VIDEO / 3) || !chunk.videoBase64.length || chunk.videoBase64.length % 4 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(chunk.videoBase64) || !HASH.test(chunk.sha256 || "")) throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
119
|
+
let bytes; try { const binary = atob(chunk.videoBase64); if (btoa(binary) !== chunk.videoBase64) throw new Error(); bytes = Uint8Array.from(binary, char => char.charCodeAt(0)); } catch { throw failure("REVIEW_NATIVE_MEDIA_INVALID"); }
|
|
120
|
+
if (bytes.length < 16 || bytes.length > MAX_VIDEO || new TextDecoder().decode(bytes.slice(4, 8)) !== "ftyp") throw failure("REVIEW_NATIVE_MEDIA_INVALID");
|
|
121
|
+
return { input: { source, chunk, ...(raw.workTitle === undefined ? {} : { workTitle: raw.workTitle }) }, metadata: meta, bytes, sha256: chunk.sha256, priorContext: text(raw.priorContext ?? "", 12000).trim() };
|
|
122
|
+
}
|
|
123
|
+
function safeReferences(value) {
|
|
124
|
+
if (Array.isArray(value)) { for (const item of value) safeReferences(item); return; }
|
|
125
|
+
if (!object(value)) { if (typeof value === "string" && /^\s*(?:data|blob|file):/iu.test(value)) throw failure("REVIEW_NATIVE_INPUT_INVALID"); return; }
|
|
126
|
+
for (const [key, child] of Object.entries(value)) {
|
|
127
|
+
if (/^(?:owneruserid|ownerid|userid|inputhash|namespace|assetid|localpath|filepath|path|filename|url|asseturl|objectkey|bucket|videobase64|audiobase64|base64data|dataurl|buffer|credentials|cookie|sessionid|sessiontoken|token|secret|byok)$/u.test(key.replace(/[^a-z0-9]/giu, "").toLowerCase())
|
|
128
|
+
|| /apikey|authorization|accesstoken|refreshtoken|clientsecret|password|privatekey/iu.test(key)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
129
|
+
safeReferences(child);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function logicalInput(stage, body, identity) {
|
|
133
|
+
if (body === null) return null;
|
|
134
|
+
if (stage === "analyzing") {
|
|
135
|
+
fields(body, ["source", "chunk", "workTitle", "priorContext"]);
|
|
136
|
+
return { priorContext: text(body.priorContext ?? "", 12000).trim() };
|
|
137
|
+
}
|
|
138
|
+
const raw = fields(body, ["chunk", "chunkId", "chunks", "story"], ["chunks", "story"]);
|
|
139
|
+
const chunkId = raw.chunkId ?? raw.chunk?.chunk?.chunkId ?? raw.chunk?.chunkId;
|
|
140
|
+
if (chunkId !== identity.metadata.chunkId || !Array.isArray(raw.chunks) || raw.chunks.length < 1 || raw.chunks.length > 500 || !object(raw.story)) throw failure("REVIEW_NATIVE_INPUT_INVALID");
|
|
141
|
+
safeReferences({ chunks: raw.chunks, story: raw.story });
|
|
142
|
+
const matches = raw.chunks.filter(item => item?.chunkId === chunkId), receipt = matches[0];
|
|
143
|
+
if (matches.length !== 1 || receipt.sha256 !== identity.sha256 || ["sourceId", "startMs", "endMs", "sourceDurationMs", "hasAudio", "videoId", "workTitle"].some(key => receipt[key] !== identity.metadata[key])) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
144
|
+
if (encoder.encode(JSON.stringify({ chunks: raw.chunks, story: raw.story })).length > MAX_TEXT) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
145
|
+
return { chunkId, chunks: raw.chunks, story: raw.story };
|
|
146
|
+
}
|
|
147
|
+
/** Only the actual HTTP adapters attach typed unknown-submission metadata. */
|
|
148
|
+
export function attachReviewNativeJobFailureMetadata(error, payload) {
|
|
149
|
+
if (!object(error) || !object(payload) || payload.submissionState !== "unknown" || payload.retryAllowed !== false || payload.paidReplayAllowed !== false
|
|
150
|
+
|| !UUID.test(payload.requestId || "") || !JOB.test(payload.jobId || "") || payload.recoveryAction !== "read-known-job") return false;
|
|
151
|
+
const stage = Object.keys(stages).find(key => payload.statusPath === `${stages[key].path}/${payload.jobId}`);
|
|
152
|
+
if (!stage) return false;
|
|
153
|
+
knownFailures.set(error, { stage, requestId: payload.requestId, jobId: payload.jobId }); return true;
|
|
154
|
+
}
|
|
155
|
+
function jobFrom(payload, stage, requestId, jobId) {
|
|
156
|
+
const raw = capture(payload, 8 * 1024 * 1024 + 4096), job = raw.job;
|
|
157
|
+
if (raw.ok !== true || raw.async !== true || raw.requestId !== requestId || !object(job) || !JOB.test(job.id || "") || jobId && jobId !== job.id
|
|
158
|
+
|| job.type !== stages[stage].type || !["queued", "pending", "running", "completed", "failed", "cancelled", "unknown", "interrupted", "cancel_requested"].includes(job.status)
|
|
159
|
+
|| job.workflow?.paidReplayAllowed !== false || job.workflow?.recovery !== "read-only-known-job") throw failure("REVIEW_NATIVE_RESPONSE_INVALID");
|
|
160
|
+
return job;
|
|
161
|
+
}
|
|
162
|
+
export function unwrapReviewNativeJobResult(stage, value, suppliedReference) {
|
|
163
|
+
if (!stages[stage]) throw failure("REVIEW_NATIVE_STAGE_INVALID");
|
|
164
|
+
const ref = mediaIdentity(suppliedReference, true), result = capture(value, 8 * 1024 * 1024), native = result[stages[stage].field];
|
|
165
|
+
if (result.kind !== stages[stage].kind || result.assetId !== ref.assetId || result.namespace !== ref.namespace || result.mediaInputHash !== ref.inputHash
|
|
166
|
+
|| result.accountingState !== "settled" || result.paidReplayAllowed !== false || result.hasAccountCostEvents !== true || !object(native)
|
|
167
|
+
|| native.chunkId !== ref.metadata.chunkId || !Array.isArray(result.accountCostEvents) || !result.accountCostEvents.length
|
|
168
|
+
|| !Array.isArray(result.acceptedTaskCosts) || !object(result.usage) || !Number.isFinite(result.costUsd) || result.costUsd < 0
|
|
169
|
+
|| typeof result.operationId !== "string" || !new RegExp(`^bg-review-${stage === "analyzing" ? "chunk" : "evidence"}-v1:[a-f0-9]{64}$`, "u").test(result.operationId)
|
|
170
|
+
|| result.accountCostEvents.some((event, index) => event.accountCostEventId !== `${result.operationId}:${index + 1}` || event.accountCostRecorded !== true && event.accountCostSuppressedByReset !== true)) throw failure("REVIEW_NATIVE_RESULT_INVALID");
|
|
171
|
+
if (stage === "analyzing" && (native.sha256 !== ref.sha256 || ["sourceId", "startMs", "endMs", "sourceDurationMs", "hasAudio", "videoId", "workTitle"].some(key => native[key] !== ref.metadata[key]))) throw failure("REVIEW_NATIVE_RESULT_MISMATCH");
|
|
172
|
+
if (stage === "verifying" && (native.verificationKind !== "native-video-audio-recheck" || native.coverage?.startMs !== ref.metadata.startMs || native.coverage?.endMs !== ref.metadata.endMs
|
|
173
|
+
|| native.coverage?.nativeAudioSubmitted !== ref.metadata.hasAudio || native.selectedSourceIds?.length !== 1 || native.selectedSourceIds[0] !== ref.metadata.sourceId)) throw failure("REVIEW_NATIVE_RESULT_MISMATCH");
|
|
174
|
+
return freeze({ ...native, ...Object.fromEntries(["operationId", "accountCostEvents", "acceptedTaskCosts", "hasAccountCostEvents", "accountingState", "costUsd", "usage", "model", "provider", "generatedAt", "paidReplayAllowed", "requiresHumanReview"].filter(key => result[key] !== undefined).map(key => [key, result[key]])) });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createReviewNativeJobClient({ request, getAuthority, apiScope, pollMs = 1000, readTimeoutMs = 30000,
|
|
178
|
+
now = Date.now, uuid = () => globalThis.crypto.randomUUID(), setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
179
|
+
if (typeof request !== "function" || typeof getAuthority !== "function" || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 10000
|
|
180
|
+
|| !Number.isSafeInteger(readTimeoutMs) || readTimeoutMs < 1 || readTimeoutMs > 60000) throw new TypeError("Native review transport dependencies required");
|
|
181
|
+
const scope = scopeOf(apiScope);
|
|
182
|
+
async function run(stage, body, options = {}) {
|
|
183
|
+
if (!stages[stage]) throw failure("REVIEW_NATIVE_STAGE_INVALID");
|
|
184
|
+
// Capture descriptors and all input values before the first await, including
|
|
185
|
+
// before crypto hashing, capability discovery or journal persistence.
|
|
186
|
+
const captured = body == null ? null : capture(body, 4 * Math.ceil(MAX_VIDEO / 3) + MAX_TEXT + 32768);
|
|
187
|
+
const checkpoint = captureReviewNativeCheckpoint(Object.fromEntries(["transport", "apiScope", "mediaAttempt", "mediaReference", "submissionAttempted", "requestBinding", "jobId"].filter(key => options[key] !== undefined && options[key] !== "").map(key => [key, options[key]])));
|
|
188
|
+
const { requestId, onCheckpoint, signal } = options, authority = getAuthority();
|
|
189
|
+
if (!authority) throw failure("REVIEW_NATIVE_AUTHORITY_REQUIRED");
|
|
190
|
+
if (!UUID.test(requestId || "")) throw failure("REVIEW_NATIVE_ID_INVALID");
|
|
191
|
+
const recovering = checkpoint.transport !== undefined || checkpoint.mediaAttempt !== undefined || checkpoint.submissionAttempted !== undefined || checkpoint.jobId !== undefined;
|
|
192
|
+
if (recovering && (checkpoint.transport !== REVIEW_NATIVE_TRANSPORT || !checkpoint.apiScope || checkpoint.submissionAttempted === undefined
|
|
193
|
+
|| !checkpoint.requestBinding || !checkpoint.mediaAttempt && !checkpoint.mediaReference)) throw failure("REVIEW_NATIVE_CHECKPOINT_INVALID");
|
|
194
|
+
if (checkpoint.mediaReference && !checkpoint.apiScope) throw failure("REVIEW_NATIVE_SCOPE_INVALID");
|
|
195
|
+
if (checkpoint.apiScope && checkpoint.apiScope !== scope) throw failure("REVIEW_NATIVE_SCOPE_CHANGED");
|
|
196
|
+
if (checkpoint.jobId && checkpoint.submissionAttempted !== true || checkpoint.submissionAttempted === true && !checkpoint.mediaReference) throw failure("REVIEW_NATIVE_CHECKPOINT_INVALID");
|
|
197
|
+
let state = { ...checkpoint }, lastJob, uploadStatus, uploadInput, logical;
|
|
198
|
+
const timeoutMs = options.timeoutMs ?? 600000;
|
|
199
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 900000) throw new TypeError("Invalid native review deadline");
|
|
200
|
+
const deadline = now() + timeoutMs;
|
|
201
|
+
const check = () => {
|
|
202
|
+
if (getAuthority() !== authority) throw failure("REVIEW_NATIVE_OWNER_CHANGED");
|
|
203
|
+
if (signal?.aborted) throw failure("REVIEW_NATIVE_WAIT_ABORTED");
|
|
204
|
+
if (now() >= deadline) throw failure("REVIEW_NATIVE_WAIT_TIMEOUT");
|
|
205
|
+
};
|
|
206
|
+
async function bounded(callback, maximum = readTimeoutMs) {
|
|
207
|
+
check(); const controller = new AbortController(); let timer, listener;
|
|
208
|
+
const stop = new Promise((_, reject) => {
|
|
209
|
+
listener = () => { const error = failure("REVIEW_NATIVE_WAIT_ABORTED"); controller.abort(error); reject(error); };
|
|
210
|
+
signal?.addEventListener("abort", listener, { once: true });
|
|
211
|
+
timer = setTimer(() => { const error = failure("REVIEW_NATIVE_WAIT_TIMEOUT"); controller.abort(error); reject(error); }, Math.min(maximum, deadline - now()));
|
|
212
|
+
});
|
|
213
|
+
try { const value = await Promise.race([Promise.resolve().then(() => { check(); return callback(controller.signal); }), stop]); check(); return value; }
|
|
214
|
+
finally { clearTimer(timer); signal?.removeEventListener("abort", listener); }
|
|
215
|
+
}
|
|
216
|
+
const call = (path, init, maximum) => bounded(signal => serializedRequest(scope, authority, check,
|
|
217
|
+
() => { if (signal.aborted) throw signal.reason || failure("REVIEW_NATIVE_WAIT_ABORTED");
|
|
218
|
+
return request(path, { ...init, cache: "no-store", signal, timeoutMs: maximum || readTimeoutMs }); }), maximum);
|
|
219
|
+
// Durable writes are awaited to completion, never raced against timeout.
|
|
220
|
+
const persist = async patch => {
|
|
221
|
+
check(); state = captureReviewNativeCheckpoint({ ...state, ...patch });
|
|
222
|
+
if (typeof onCheckpoint !== "function") throw failure("REVIEW_NATIVE_JOURNAL_REQUIRED");
|
|
223
|
+
await onCheckpoint(state); check();
|
|
224
|
+
};
|
|
225
|
+
async function readUpload(value) {
|
|
226
|
+
const raw = capture(value, 16384), attempt = state.mediaAttempt;
|
|
227
|
+
if (!attempt || raw.attemptId !== attempt.attemptId || raw.automaticUploadRetryAllowed !== false
|
|
228
|
+
|| !["ready", "reserved", "unconfirmed", "not_found", "deleting", "deleted"].includes(raw.status)) throw failure("REVIEW_NATIVE_UPLOAD_RESPONSE_INVALID");
|
|
229
|
+
if (raw.status === "ready") {
|
|
230
|
+
if (raw.ok !== true) throw failure("REVIEW_NATIVE_UPLOAD_RESPONSE_INVALID");
|
|
231
|
+
const reference = mediaIdentity(raw.reference, true); await checkIdentity(reference); check();
|
|
232
|
+
if (reference.inputHash !== attempt.inputHash || reference.sha256 !== attempt.sha256 || reference.byteLength !== attempt.byteLength
|
|
233
|
+
|| raw.assetId !== reference.assetId || raw.namespace !== reference.namespace || raw.inputHash !== reference.inputHash
|
|
234
|
+
|| state.mediaReference && JSON.stringify(state.mediaReference) !== JSON.stringify(reference)) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
235
|
+
await persist({ mediaReference: reference });
|
|
236
|
+
} else if (raw.status !== "not_found" && (raw.inputHash !== attempt.inputHash || !ASSET.test(raw.assetId || "") || !HASH.test(raw.namespace || ""))) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
237
|
+
return raw;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
check();
|
|
241
|
+
if (state.mediaAttempt) { await checkIdentity(state.mediaAttempt); check(); }
|
|
242
|
+
if (state.mediaReference) { await checkIdentity(state.mediaReference); check(); }
|
|
243
|
+
if (state.mediaAttempt && state.mediaReference && state.mediaAttempt.inputHash !== state.mediaReference.inputHash) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH");
|
|
244
|
+
const suppliedMedia = stage === "analyzing" && (captured?.source || captured?.chunk) ? captured : stage === "verifying" ? captured?.chunk : null;
|
|
245
|
+
const existingIdentity = state.mediaReference || state.mediaAttempt;
|
|
246
|
+
if (suppliedMedia && existingIdentity) {
|
|
247
|
+
const media = uploadShape(suppliedMedia);
|
|
248
|
+
const hash = await digest(JSON.stringify({ version: 1, metadata: media.metadata, sha256: media.sha256, byteLength: media.bytes.length })); check();
|
|
249
|
+
if (hash !== existingIdentity.inputHash || await digest(media.bytes) !== existingIdentity.sha256) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH"); check();
|
|
250
|
+
}
|
|
251
|
+
if (state.submissionAttempted === true && !state.jobId) throw failure("REVIEW_NATIVE_ACCEPTANCE_UNKNOWN");
|
|
252
|
+
if (!recovering && options.readOnly !== true) {
|
|
253
|
+
const cap = await call(REVIEW_NATIVE_CAPABILITY, { method: "GET" });
|
|
254
|
+
if (cap?.ok !== true || cap.kind !== "review-native" || cap.version !== 2 || cap.automaticUploadRetryAllowed !== false || cap.paidReplayAllowed !== false || !["queue-v2", "legacy-sync", "unavailable"].includes(cap.mode)) throw failure("REVIEW_NATIVE_CAPABILITY_INVALID");
|
|
255
|
+
if (cap.mode === "legacy-sync") {
|
|
256
|
+
if (state.mediaReference) throw failure("REVIEW_NATIVE_CAPABILITY_CHANGED");
|
|
257
|
+
if (!captured) throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
258
|
+
let legacy = captured;
|
|
259
|
+
if (stage === "verifying" && !captured.chunk) {
|
|
260
|
+
if (typeof options.getLegacyMedia !== "function") throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
261
|
+
const media = capture(await bounded(() => options.getLegacyMedia()), 4 * Math.ceil(MAX_VIDEO / 3) + 16384);
|
|
262
|
+
legacy = { chunks: captured.chunks, story: captured.story, chunk: media };
|
|
263
|
+
}
|
|
264
|
+
return await call(`/api/tracker/review/${stages[stage].legacy}`, { method: "POST", body: JSON.stringify({ ...legacy, costEventId: requestId }) }, timeoutMs);
|
|
265
|
+
}
|
|
266
|
+
if (cap.mode === "unavailable") throw failure("REVIEW_NATIVE_UNAVAILABLE");
|
|
267
|
+
if (!Array.isArray(cap.jobTypes) || !cap.jobTypes.includes(stages[stage].type)) throw failure("REVIEW_NATIVE_UNAVAILABLE");
|
|
268
|
+
}
|
|
269
|
+
if (!state.mediaReference && !state.mediaAttempt) {
|
|
270
|
+
if (options.readOnly === true || !captured) throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
271
|
+
uploadInput = uploadShape(stage === "analyzing" ? captured : captured.chunk);
|
|
272
|
+
if (await digest(uploadInput.bytes) !== uploadInput.sha256) throw failure("REVIEW_NATIVE_MEDIA_MISMATCH"); check();
|
|
273
|
+
const base = { metadata: uploadInput.metadata, sha256: uploadInput.sha256, byteLength: uploadInput.bytes.length };
|
|
274
|
+
const mediaAttempt = mediaIdentity({ attemptId: uuid(), ...base, inputHash: await digest(JSON.stringify({ version: 1, ...base })) }); check();
|
|
275
|
+
logical = logicalInput(stage, captured, mediaAttempt);
|
|
276
|
+
const requestBinding = await digest(JSON.stringify({ requestId, stage, mediaInputHash: mediaAttempt.inputHash, ...logical })); check();
|
|
277
|
+
await persist({ transport: REVIEW_NATIVE_TRANSPORT, apiScope: scope, mediaAttempt, submissionAttempted: false, requestBinding });
|
|
278
|
+
uploadStatus = await readUpload(await call(REVIEW_NATIVE_ATTEMPTS, { method: "POST", body: JSON.stringify({ attemptId: mediaAttempt.attemptId, input: uploadInput.input, consent: true }) }));
|
|
279
|
+
}
|
|
280
|
+
if (!state.mediaReference && state.mediaAttempt) {
|
|
281
|
+
uploadStatus = await readUpload(await call(`${REVIEW_NATIVE_ATTEMPTS}/${state.mediaAttempt.attemptId}`, { method: "GET" }));
|
|
282
|
+
if (uploadStatus.status === "unconfirmed") uploadStatus = await readUpload(await call(`${REVIEW_NATIVE_ATTEMPTS}/${state.mediaAttempt.attemptId}/reconcile`, { method: "POST" }));
|
|
283
|
+
if (options.readOnly === true) return freeze({ kind: "review-media-attempt", ...uploadStatus });
|
|
284
|
+
if (!state.mediaReference) throw failure("REVIEW_NATIVE_UPLOAD_UNCONFIRMED");
|
|
285
|
+
}
|
|
286
|
+
if (!state.mediaReference) throw failure("REVIEW_NATIVE_MEDIA_REQUIRED");
|
|
287
|
+
logical ??= logicalInput(stage, captured, state.mediaReference);
|
|
288
|
+
if (logical) {
|
|
289
|
+
const binding = await digest(JSON.stringify({ requestId, stage, mediaInputHash: state.mediaReference.inputHash, ...logical })); check();
|
|
290
|
+
if (state.requestBinding && state.requestBinding !== binding) throw failure("REVIEW_NATIVE_INPUT_CHANGED");
|
|
291
|
+
if (!state.requestBinding) state.requestBinding = binding;
|
|
292
|
+
}
|
|
293
|
+
if (!state.jobId) {
|
|
294
|
+
if (options.readOnly === true) return freeze({ kind: "review-media-attempt", ...(uploadStatus || { status: "ready", reference: state.mediaReference, automaticUploadRetryAllowed: false }) });
|
|
295
|
+
if (!logical) throw failure("REVIEW_NATIVE_INPUT_REQUIRED");
|
|
296
|
+
if (state.mediaReference.expiresAt <= now()) throw failure("REVIEW_NATIVE_MEDIA_EXPIRED");
|
|
297
|
+
const ref = state.mediaReference, input = { assetId: ref.assetId, namespace: ref.namespace, mediaInputHash: ref.inputHash, ...logical };
|
|
298
|
+
if (encoder.encode(JSON.stringify(input)).length > (stage === "analyzing" ? 65536 : MAX_TEXT)) throw failure("REVIEW_NATIVE_INPUT_LIMIT");
|
|
299
|
+
await persist({ transport: REVIEW_NATIVE_TRANSPORT, apiScope: scope, submissionAttempted: true, requestBinding: state.requestBinding });
|
|
300
|
+
lastJob = jobFrom(await call(stages[stage].path, { method: "POST", body: JSON.stringify({ requestId, input, consent: true }) }), stage, requestId);
|
|
301
|
+
await persist({ jobId: lastJob.id });
|
|
302
|
+
}
|
|
303
|
+
for (;;) {
|
|
304
|
+
if (!lastJob) lastJob = jobFrom(await call(`${stages[stage].path}/${state.jobId}`, { method: "GET" }), stage, requestId, state.jobId);
|
|
305
|
+
if (options.readOnly === true) return lastJob;
|
|
306
|
+
if (lastJob.status === "completed") return unwrapReviewNativeJobResult(stage, lastJob.result, state.mediaReference);
|
|
307
|
+
if (!["queued", "pending", "running"].includes(lastJob.status)) throw failure("REVIEW_NATIVE_JOB_STOPPED", typeof lastJob.error === "string" && lastJob.error ? lastJob.error.slice(0, 1000) : undefined);
|
|
308
|
+
let timer; try { await bounded(() => new Promise(resolve => { timer = setTimer(resolve, pollMs); }), pollMs + readTimeoutMs); }
|
|
309
|
+
finally { if (timer !== undefined) clearTimer(timer); }
|
|
310
|
+
lastJob = null;
|
|
311
|
+
}
|
|
312
|
+
} catch (caught) {
|
|
313
|
+
if (getAuthority() !== authority) throw failure("REVIEW_NATIVE_OWNER_CHANGED");
|
|
314
|
+
let error = caught instanceof Error ? caught : failure("REVIEW_NATIVE_REQUEST_FAILED");
|
|
315
|
+
const known = knownFailures.get(error);
|
|
316
|
+
if (state.submissionAttempted === true && !state.jobId && known?.stage === stage && known.requestId === requestId) {
|
|
317
|
+
try { await persist({ jobId: known.jobId }); } catch (storageError) { error = storageError; }
|
|
318
|
+
if (getAuthority() !== authority) throw failure("REVIEW_NATIVE_OWNER_CHANGED");
|
|
319
|
+
}
|
|
320
|
+
if (!(error instanceof Error)) error = failure("REVIEW_NATIVE_REQUEST_FAILED");
|
|
321
|
+
// Raw error.jobId is not a recovery capability. Only an accepted response
|
|
322
|
+
// or adapter-branded, matching typed failure can populate this identity.
|
|
323
|
+
if (!state.jobId) delete error.jobId;
|
|
324
|
+
if (state.transport) Object.assign(error, state, { requestId, reconciliationRequired: true, paidReplayAllowed: false, automaticUploadRetryAllowed: false });
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return Object.freeze({ run, read: (stage, options = {}) => run(stage, null, { ...options, readOnly: true }) });
|
|
329
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Shared browser/MCP transport. No credentials, paid retries or server failover.
|
|
2
|
+
export const REVIEW_STORY_TRANSPORT = "review-story-queue-v1";
|
|
3
|
+
export const REVIEW_STORY_JOBS = "/api/tracker/review/story-jobs";
|
|
4
|
+
export const REVIEW_STORY_CAPABILITY = "/api/tracker/review/story-execution";
|
|
5
|
+
const UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
6
|
+
const ID = new RegExp(`^${UUID}$`, "u"), JOB_ID = new RegExp(`^epoch-v2-${UUID}$`, "u");
|
|
7
|
+
const statuses = new Set(["queued", "pending", "running", "completed", "failed", "interrupted", "unknown", "cancelled", "cancel_requested"]);
|
|
8
|
+
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
const knownErrorJobs = new WeakMap();
|
|
10
|
+
// Only HTTP adapters call this with the typed server failure body. A copied
|
|
11
|
+
// public jobId/error property alone never becomes a trusted recovery handle.
|
|
12
|
+
export function attachReviewStoryJobFailureMetadata(error, payload) {
|
|
13
|
+
if (!error || typeof error !== "object" || !object(payload) || payload.submissionState !== "unknown"
|
|
14
|
+
|| payload.retryAllowed !== false || payload.paidReplayAllowed !== false || !ID.test(payload.requestId || "")
|
|
15
|
+
|| !JOB_ID.test(payload.jobId || "") || payload.statusPath !== `${REVIEW_STORY_JOBS}/${payload.jobId}`
|
|
16
|
+
|| payload.recoveryAction !== "read-known-job") return false;
|
|
17
|
+
const identity = { requestId: payload.requestId, jobId: payload.jobId, statusPath: payload.statusPath };
|
|
18
|
+
knownErrorJobs.set(error, identity);
|
|
19
|
+
Object.assign(error, identity, { paidReplayAllowed: false, retryAllowed: false, reconciliationRequired: true });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
const failure = (code, message) => Object.assign(new Error(message), { code, paidReplayAllowed: false });
|
|
23
|
+
const validScope = value => {
|
|
24
|
+
let url; try { url = new URL(value); } catch { throw failure("REVIEW_STORY_SCOPE_INVALID", "분석 서버를 확인할 수 없습니다."); }
|
|
25
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw failure("REVIEW_STORY_SCOPE_INVALID", "분석 서버를 확인할 수 없습니다.");
|
|
26
|
+
return url.href.replace(/\/$/u, "");
|
|
27
|
+
};
|
|
28
|
+
function jobFrom(payload, requestId, expectedId) {
|
|
29
|
+
const job = payload?.job;
|
|
30
|
+
if (payload?.ok !== true || payload.async !== true || payload.requestId !== requestId || !object(job)
|
|
31
|
+
|| !JOB_ID.test(job.id || "") || (expectedId && job.id !== expectedId)
|
|
32
|
+
|| job.type !== "review:synthesize-story" || !statuses.has(job.status)) {
|
|
33
|
+
throw failure("REVIEW_STORY_RESPONSE_INVALID", "저장된 줄거리 작업을 안전하게 확인하지 못했습니다.");
|
|
34
|
+
}
|
|
35
|
+
if (job.result !== null && job.result !== undefined && new TextEncoder().encode(JSON.stringify(job.result)).length > 8 * 1024 * 1024) {
|
|
36
|
+
throw failure("REVIEW_STORY_RESULT_LIMIT", "저장된 줄거리 결과가 크기 한도를 초과했습니다.");
|
|
37
|
+
}
|
|
38
|
+
return job;
|
|
39
|
+
}
|
|
40
|
+
function captureInput(input) {
|
|
41
|
+
if (!object(input) || Object.keys(input).some(key => !["workTitle", "sources", "chunks"].includes(key))
|
|
42
|
+
|| !Array.isArray(input.sources) || !Array.isArray(input.chunks)) throw failure("REVIEW_STORY_INPUT_INVALID", "전체 줄거리 분석 입력을 확인해 주세요.");
|
|
43
|
+
const json = JSON.stringify(input);
|
|
44
|
+
if (new TextEncoder().encode(json).length > 4 * 1024 * 1024) throw failure("REVIEW_STORY_INPUT_LIMIT", "전체 분석 자료가 4MiB를 초과했습니다.");
|
|
45
|
+
return JSON.parse(json);
|
|
46
|
+
}
|
|
47
|
+
export function createReviewStoryJobClient({ request, getAuthority, apiScope, pollMs = 1000, readTimeoutMs = 30000,
|
|
48
|
+
now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
49
|
+
if (typeof request !== "function" || typeof getAuthority !== "function" || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 10000
|
|
50
|
+
|| !Number.isSafeInteger(readTimeoutMs) || readTimeoutMs < 1 || readTimeoutMs > 60000) throw new TypeError("Review story transport dependencies required");
|
|
51
|
+
const scope = validScope(apiScope);
|
|
52
|
+
async function run(input, options = {}) {
|
|
53
|
+
const { requestId, onSubmissionAttempt, onJobAccepted, signal } = options;
|
|
54
|
+
const authority = getAuthority();
|
|
55
|
+
let jobId = options.jobId || "", attempted = options.transport === REVIEW_STORY_TRANSPORT || Boolean(jobId), lastJob;
|
|
56
|
+
if (!authority) throw failure("REVIEW_STORY_AUTHORITY_REQUIRED", "로그인 후 줄거리 분석을 이용해 주세요.");
|
|
57
|
+
if (!ID.test(requestId || "") || (jobId && !JOB_ID.test(jobId))) throw failure("REVIEW_STORY_ID_INVALID", "저장된 줄거리 작업 ID를 확인해 주세요.");
|
|
58
|
+
if ((options.transport && options.transport !== REVIEW_STORY_TRANSPORT) || (attempted && !options.apiScope)) throw failure("REVIEW_STORY_SCOPE_INVALID", "기존 작업의 서버 기록이 없습니다. 다시 제출하지 않습니다.");
|
|
59
|
+
if (options.apiScope && validScope(options.apiScope) !== scope) throw failure("REVIEW_STORY_SCOPE_CHANGED", "원래 분석 서버에서 기존 작업을 확인해 주세요.");
|
|
60
|
+
const timeoutMs = options.timeoutMs ?? 600000;
|
|
61
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 900000) throw new TypeError("Invalid review waiting deadline");
|
|
62
|
+
const deadline = now() + timeoutMs;
|
|
63
|
+
const check = () => {
|
|
64
|
+
if (getAuthority() !== authority) throw failure("REVIEW_STORY_OWNER_CHANGED", "로그인 상태가 변경되어 이전 분석 조회를 중단했습니다.");
|
|
65
|
+
if (signal?.aborted) throw failure("REVIEW_STORY_WAIT_ABORTED", "분석 기다리기를 중단했습니다. 서버 작업을 취소하거나 재생성하지 않습니다.");
|
|
66
|
+
if (now() >= deadline) throw failure("REVIEW_STORY_WAIT_TIMEOUT", "분석 대기 시간이 초과되었습니다. 기존 작업만 조회하세요.");
|
|
67
|
+
};
|
|
68
|
+
async function bounded(callback, maximum = readTimeoutMs) {
|
|
69
|
+
check();
|
|
70
|
+
const controller = new AbortController(); let timer, listener;
|
|
71
|
+
const stop = new Promise((_, reject) => {
|
|
72
|
+
listener = () => { const error = failure("REVIEW_STORY_WAIT_ABORTED", "분석 기다리기를 중단했습니다."); controller.abort(error); reject(error); };
|
|
73
|
+
signal?.addEventListener("abort", listener, { once: true });
|
|
74
|
+
timer = setTimer(() => { const error = failure("REVIEW_STORY_WAIT_TIMEOUT", "분석 대기 시간이 초과되었습니다. 기존 작업만 조회하세요."); controller.abort(error); reject(error); }, Math.min(maximum, deadline - now()));
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
const value = await Promise.race([Promise.resolve().then(() => { check(); return callback(controller.signal); }), stop]);
|
|
78
|
+
check(); return value;
|
|
79
|
+
} finally { clearTimer(timer); signal?.removeEventListener("abort", listener); }
|
|
80
|
+
}
|
|
81
|
+
const call = (path, init, maximum) => bounded(signal => request(path, { ...init, cache: "no-store", signal, timeoutMs: maximum || readTimeoutMs }), maximum);
|
|
82
|
+
// Persistence is deliberately awaited, not raced against abort/deadline.
|
|
83
|
+
const persist = async (callback, value) => { check(); await callback(value); check(); };
|
|
84
|
+
try {
|
|
85
|
+
if (attempted && !jobId) throw failure("REVIEW_STORY_ACCEPTANCE_UNKNOWN", "접수 응답에서 작업 ID를 받지 못했습니다. 유료 단계를 다시 제출하지 않습니다.");
|
|
86
|
+
if (!jobId) {
|
|
87
|
+
const captured = captureInput(input);
|
|
88
|
+
const capability = await call(REVIEW_STORY_CAPABILITY, { method: "GET" });
|
|
89
|
+
if (capability?.version !== 1 || capability.kind !== "review-story" || capability.recovery !== "read-only-known-job"
|
|
90
|
+
|| capability.paidReplayAllowed !== false || !["legacy-sync", "queue-v2", "unavailable"].includes(capability.mode)) throw failure("REVIEW_STORY_CAPABILITY_INVALID", "줄거리 실행 방식이 확인되지 않아 생성을 시작하지 않았습니다.");
|
|
91
|
+
if (capability.mode === "unavailable") throw failure("REVIEW_STORY_UNAVAILABLE", "이 서버에서 줄거리 작업을 실행할 수 없습니다.");
|
|
92
|
+
if (capability.mode === "legacy-sync") return await call("/api/tracker/review/synthesize-story", { method: "POST", body: JSON.stringify(captured) }, timeoutMs);
|
|
93
|
+
if (typeof onSubmissionAttempt !== "function" || typeof onJobAccepted !== "function") throw failure("REVIEW_STORY_JOURNAL_REQUIRED", "작업 기록을 저장할 수 없어 분석을 시작하지 않았습니다.");
|
|
94
|
+
attempted = true;
|
|
95
|
+
await persist(onSubmissionAttempt, { transport: REVIEW_STORY_TRANSPORT, apiScope: scope });
|
|
96
|
+
lastJob = jobFrom(await call(REVIEW_STORY_JOBS, { method: "POST", body: JSON.stringify({ requestId, input: captured }) }), requestId);
|
|
97
|
+
jobId = lastJob.id;
|
|
98
|
+
await persist(onJobAccepted, { jobId, requestId, transport: REVIEW_STORY_TRANSPORT, apiScope: scope });
|
|
99
|
+
}
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (!lastJob) lastJob = jobFrom(await call(`${REVIEW_STORY_JOBS}/${jobId}`, { method: "GET" }), requestId, jobId);
|
|
102
|
+
if (options.readOnly === true) return lastJob;
|
|
103
|
+
if (lastJob.status === "completed") {
|
|
104
|
+
if (!object(lastJob.result)) throw failure("REVIEW_STORY_RESULT_MISSING", "저장된 분석 결과를 확인하지 못했습니다.");
|
|
105
|
+
return lastJob.result;
|
|
106
|
+
}
|
|
107
|
+
if (!["queued", "pending", "running"].includes(lastJob.status)) throw failure("REVIEW_STORY_JOB_STOPPED", typeof lastJob.error === "string" ? lastJob.error.slice(0, 1000) : "기존 줄거리 작업을 확인해야 합니다. 다시 생성하지 않습니다.");
|
|
108
|
+
let timer;
|
|
109
|
+
try { await bounded(() => new Promise(resolve => { timer = setTimer(resolve, pollMs); }), pollMs + readTimeoutMs); }
|
|
110
|
+
finally { if (timer !== undefined) clearTimer(timer); }
|
|
111
|
+
lastJob = null;
|
|
112
|
+
}
|
|
113
|
+
} catch (caught) {
|
|
114
|
+
if (getAuthority() !== authority) throw failure("REVIEW_STORY_OWNER_CHANGED", "로그인 상태가 변경되어 이전 분석 조회를 중단했습니다.");
|
|
115
|
+
let error = caught instanceof Error ? caught : failure("REVIEW_STORY_REQUEST_FAILED", "분석 상태를 확인할 수 없습니다.");
|
|
116
|
+
const known = knownErrorJobs.get(error);
|
|
117
|
+
if (attempted && !jobId && known?.requestId === requestId) {
|
|
118
|
+
jobId = known.jobId;
|
|
119
|
+
try { await persist(onJobAccepted, { jobId, requestId, transport: REVIEW_STORY_TRANSPORT, apiScope: scope }); }
|
|
120
|
+
catch (storageError) { error = storageError; }
|
|
121
|
+
if (getAuthority() !== authority) throw failure("REVIEW_STORY_OWNER_CHANGED", "로그인 상태가 변경되어 이전 분석 조회를 중단했습니다.");
|
|
122
|
+
}
|
|
123
|
+
if (attempted) Object.assign(error, { requestId, jobId, transport: REVIEW_STORY_TRANSPORT, apiScope: scope, reconciliationRequired: true, paidReplayAllowed: false });
|
|
124
|
+
if (object(lastJob?.result)) for (const key of ["operationId", "accountCostEvents", "acceptedTaskCosts", "costUsd", "usage", "model", "partialProviderUsage"]) {
|
|
125
|
+
if (lastJob.result[key] !== undefined) error[key] = lastJob.result[key];
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return Object.freeze({ run, read: (jobId, options = {}) => run(null, { ...options, jobId, readOnly: true }) });
|
|
131
|
+
}
|
package/lib/review-tools.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { wavDurationSec } from "./audio-duration.mjs";
|
|
|
9
9
|
import { mp3DurationSec } from "./mp3-duration.mjs";
|
|
10
10
|
import { createUsageEventId } from "./usage-event.mjs";
|
|
11
11
|
import { assertReviewBudget, bindReviewRenderSources, inspectReviewLocalFile, readReviewHandle, REVIEW_MAX_FRAMES, reviewPublicSources, validateReviewSources } from "./review-sources.mjs";
|
|
12
|
-
import {
|
|
12
|
+
import { refreshFullReviewStatus, runFullReviewAnalysis, selectReviewPlanningCatalog } from "./review-full-analysis.mjs";
|
|
13
13
|
|
|
14
14
|
const spoiler = z.enum(["none", "limited", "full", "unknown"]);
|
|
15
15
|
const interval = z.object({ startMs: z.number().int().min(0), endMs: z.number().int().positive(),
|
|
@@ -96,10 +96,10 @@ export function registerReviewTools(server, config, api, { wrap = handler => han
|
|
|
96
96
|
}
|
|
97
97
|
});
|
|
98
98
|
server.tool("analyze_review_sources",
|
|
99
|
-
"영화·드라마 전체 영상+오디오를 Gemini 3.8로 분석하고 서사와 원본 근거를 재검증합니다. 기본 full은 모든 구간을 최대300초 MP4로 압축하여 클라우드에 전송하므로 사용자 업로드·과금 동의 후 allowCloudVideoUpload:true 필수. 첫 호출은 무료 원본 검사/범위 준비만, 이후 resumePath로 기본1단계씩 계속하세요. 완료된 청크는 재과금하지
|
|
99
|
+
"영화·드라마 전체 영상+오디오를 Gemini 3.8로 분석하고 서사와 원본 근거를 재검증합니다. 기본 full은 모든 구간을 최대300초 MP4로 압축하여 클라우드에 전송하므로 사용자 업로드·과금 동의 후 allowCloudVideoUpload:true 필수. 첫 호출은 무료 원본 검사/범위 준비만, 이후 resumePath로 기본1단계씩 계속하세요. 지원 서버에서는 영상 업로드·구간 분석·줄거리·원본 검증을 영속 작업으로 기록합니다. 기존 작업은 조회/업로드 확인만 하며 접수 미확정 유료 요청이나 업로드를 재전송하지 않습니다. 완료된 청크는 재과금하지 않습니다. 명시된 기존 동기 방식의 실패 재시도만 승인 후 가능합니다. 모순/근거 부족 판정은 저장하고 사람 검토를 기다립니다. 대표 프레임 점검만 원하면 analysisScope:'sample'을 명시하세요(전체 이해가 아님).",
|
|
100
100
|
{ sources: z.array(sourceInput).min(1).max(5).optional(), analysisScope: z.enum(["full", "sample"]).optional(),
|
|
101
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(),
|
|
102
|
+
maxSteps: z.number().int().min(1).max(4).optional(), retryFailedStage: z.boolean().optional().describe("기존 동기 실패 단계만 추가 과금 재시도 승인. 업로드/구간/줄거리/검증 큐의 접수 미확정 작업과 이미 받은 결과는 재전송하지 않습니다. 모순·근거 부족 판정도 재과금하지 않습니다."),
|
|
103
103
|
maxFrames: z.number().int().min(3).max(REVIEW_MAX_FRAMES).optional() },
|
|
104
104
|
wrapReview(async (args, extra) => {
|
|
105
105
|
if (args.analysisScope !== "sample") return result(await runFullReviewAnalysis({ config, api, args, companionJson,
|
|
@@ -194,7 +194,7 @@ export function registerReviewTools(server, config, api, { wrap = handler => han
|
|
|
194
194
|
}));
|
|
195
195
|
|
|
196
196
|
server.tool("review_analysis_status", "전체 원작 분석의 최신 진행률·청크 완료·원본 검증·누적 비용을 조회합니다. 유료 API를 호출하지 않습니다. 오래된 resumePath도 최신 체크포인트를 찾아 반환합니다.",
|
|
197
|
-
{ resumePath: z.string() }, wrapReview(async ({ resumePath }) => result(
|
|
197
|
+
{ resumePath: z.string() }, wrapReview(async ({ resumePath }) => result(await refreshFullReviewStatus(config, api, resumePath))));
|
|
198
198
|
|
|
199
199
|
server.tool("plan_review_video",
|
|
200
200
|
"검토한 영화·드라마 원본 카탈로그로 60~900초 리뷰를 기획합니다. 원작 사실·해석·가상해석을 구분하며 spoilerPolicy를 지킵니다. reviewSourcePath는 analyze_review_sources 결과. 영화 전체를 봤다고 가정하지 않으며 기획에 분석 과금이 발생할 수 있습니다. 반환 productionPath를 보이스 추천·리뷰 편집에 전달하세요.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aimakeall-mcp",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
4
4
|
"description": "AImakeAll MCP 서버 — Claude Code/Codex에서 자연어로 영상 기획·생성·렌더·퍼블리시 (렌더는 로컬 컴패니언)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
24
24
|
"zod": "^3.25.1"
|
|
25
25
|
},
|
|
26
|
-
"bundleDependencies": [
|
|
26
|
+
"bundleDependencies": [
|
|
27
|
+
"@modelcontextprotocol/sdk",
|
|
28
|
+
"zod"
|
|
29
|
+
],
|
|
27
30
|
"license": "UNLICENSED"
|
|
28
31
|
}
|