aimakeall-mcp 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/lib/api-client.mjs +21 -4
- package/lib/cloud-tools.mjs +11 -6
- package/lib/config.mjs +1 -1
- package/lib/durable-channel-client.mjs +30 -0
- package/lib/durable-channel-contract.mjs +60 -0
- package/lib/durable-channel-journal.mjs +53 -0
- package/lib/durable-channel-request.mjs +375 -0
- package/lib/durable-speech.mjs +16 -2
- 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/lib/speech-production-profile.mjs +21 -0
- package/lib/tts-alignment.mjs +69 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,20 @@ 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.4 공유 실행 호환성 업데이트
|
|
9
|
+
|
|
10
|
+
채널 실측 작업의 계정별 복구와 실제 발화 자막·음성 제작 스타일 보존을 추가했습니다.
|
|
11
|
+
공유 실행은 서버가 지원을 명시한 경우에만 사용하며 기존 단일 서버와 호환됩니다.
|
|
12
|
+
운영 PostgreSQL 전환이나 자동 확장이 이 패키지 게시만으로 활성화되지는 않습니다.
|
|
13
|
+
|
|
14
|
+
## 0.14.3 리뷰 분석 업데이트
|
|
15
|
+
|
|
16
|
+
전편 분석의 서버 실행 방식 조회, 계정·서버별 체크포인트 고정, 알려진 작업의 읽기 전용
|
|
17
|
+
복구를 추가했습니다. 공유 큐 실행은 서버가 명시적으로 제공할 때만 사용합니다.
|
|
18
|
+
기존 단일 서버에서는 명시된 동기 실행 방식을 유지하며, 요청 실패 후 유료 작업을
|
|
19
|
+
자동으로 다시 생성하지 않습니다. 이는 운영 PostgreSQL·자동 확장 활성화를 의미하지 않습니다.
|
|
20
|
+
실행 중인 제작은 먼저 완료한 뒤 MCP를 재연결하세요. 설치용 부트스트랩 버전은 그대로입니다.
|
|
21
|
+
|
|
8
22
|
## 설정
|
|
9
23
|
|
|
10
24
|
macOS·Windows에서 **Node.js 20 이상**이 필요합니다.
|
|
@@ -116,6 +130,14 @@ plan_shorts_video → (씬마다) generate_scene_image → get_generation_job(co
|
|
|
116
130
|
- 성공 응답의 `provider`, `primaryProvider`, `providerFallback`, `accountCostEvents`로 실제 합성 공급자와 비용을 확인합니다. 폴백 후 새 `productionPath`에 실제 보이스를 저장하며 이전 제작 핸들은 바꾸지 않습니다. 항상 최신 핸들을 다음 단계에 넘기세요.
|
|
117
131
|
- 제출 시간 초과·네트워크 단절·접수 후 결과 불명, 인증·입력·안전정책 오류에는 다른 유료 생성을 자동 실행하지 않습니다. 오류의 `fallbackStatus`, `fallbackReason`, `fallbackAvailable`, 접수된 비용을 확인하고 기존 작업부터 조회하세요. 실패했다고 비용이 없거나 환불되었다는 뜻은 아닙니다.
|
|
118
132
|
- ElevenLabs의 정확한 문자별 타임스탬프를 선택한 요청은 추정 자막으로 대체하지 않습니다. 다른 공급자 일반 합성이 ElevenLabs로 전환되더라도 `timingSource:"estimated-from-audio-duration"`인 자막이 정밀 타임스탬프로 바뀌지는 않습니다. 사용자 PC의 Companion 직접 Edge TTS는 서버 폴백 체인 밖입니다.
|
|
133
|
+
|
|
134
|
+
공유 실행용 `start_speech_generation`은 공급자 한 곳과 `requestId`를 고정합니다.
|
|
135
|
+
ElevenLabs의 실제 자막은 `options.includeAlignment:true`로 오디오와 같은 작업에서
|
|
136
|
+
요청하고, 원래 제작 핸들의 `productionProfile`을 `options.productionProfile`로 전달합니다.
|
|
137
|
+
`get_generation_job` 완료 응답은 보호된 오디오 링크와 검증된 `subtitleLines`,
|
|
138
|
+
`timingSource:"provider-character-alignment"`를 반환합니다. 응답이 끊겨도 같은 ID를
|
|
139
|
+
조회하며 다른 공급자나 기존 동기 TTS로 다시 합성하지 않습니다. 이 비동기 도구가
|
|
140
|
+
기존 `tts_narration_with_captions`의 로컬 파일·제작 핸들을 자동 갱신하는 것은 아닙니다.
|
|
119
141
|
- 이미지·영상·LLM 폴백은 대응 서버가 지원하는 동일 기능/입력 경로에서만 동작합니다. SUNO, 음성 클로닝, 정밀 정렬 등 모든 독점 기능에 호환 대체가 있는 것은 아닙니다. 상태 조회만으로 미확인 유료 작업을 재생성하지 마세요.
|
|
120
142
|
|
|
121
143
|
## 장면 이미지 준비와 영상 표현 선택
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createChannelMeasurementClient } from "./durable-channel-client.mjs";
|
|
1
2
|
|
|
2
3
|
import { createReadStream, readFileSync, statSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -262,7 +263,7 @@ export function registerCloudTools(server, config, api) {
|
|
|
262
263
|
|
|
263
264
|
server.tool(
|
|
264
265
|
"start_speech_generation",
|
|
265
|
-
"지정한 ElevenLabs/Typecast/Supertone 한 곳에 비동기 내레이션 작업을 접수합니다. 공유 실행 저장소가 필요합니다. requestId를 유지하고 get_generation_job으로 조회하세요. 대체 공급자·자동 재합성·기존 바이너리 TTS로 전환하지 않으며, 완료 오디오는 서버 소유 링크로 반환됩니다.",
|
|
266
|
+
"지정한 ElevenLabs/Typecast/Supertone 한 곳에 비동기 내레이션 작업을 접수합니다. 공유 실행 저장소가 필요합니다. ElevenLabs는 options.includeAlignment=true로 실제 발화 자막도 함께 생성하며 productionProfile로 원래 말투·스타일을 유지합니다. requestId를 유지하고 get_generation_job으로 조회하세요. 대체 공급자·자동 재합성·기존 바이너리 TTS로 전환하지 않으며, 완료 오디오는 서버 소유 링크로 반환됩니다.",
|
|
266
267
|
durableSpeechToolSchema,
|
|
267
268
|
wrapCloudHandler(config, async input => {
|
|
268
269
|
const result = await submitDurableSpeech(api, input);
|
|
@@ -1713,6 +1714,10 @@ export function registerCloudTools(server, config, api) {
|
|
|
1713
1714
|
videoUrls: z.array(z.string()).min(1).max(5).describe("표본 유튜브 URL 또는 11자 ID (조회수 상위 영상 권장)"),
|
|
1714
1715
|
},
|
|
1715
1716
|
wrapCloudHandler(config, async ({ videoUrls }) => {
|
|
1717
|
+
const durable = createChannelMeasurementClient(config, api, videoUrls);
|
|
1718
|
+
const restored = await durable.recover();
|
|
1719
|
+
if (restored) return jsonResult({ fingerprint: restored.fingerprint, usage: restored.usage, accountCostEvents: restored.accountCostEvents, backgroundJobId: restored.backgroundJobId, recovered: true, paidReplayAllowed: false });
|
|
1720
|
+
await durable.readCapability();
|
|
1716
1721
|
const specs = [];
|
|
1717
1722
|
const notes = [];
|
|
1718
1723
|
// 라우드니스·무음은 여기서 재고 "숫자만" 보낸다. 오디오를 서버로 올리면 표본 3편만
|
|
@@ -1783,16 +1788,16 @@ export function registerCloudTools(server, config, api) {
|
|
|
1783
1788
|
if (!specs.length) {
|
|
1784
1789
|
return textResult(`표본 실측에 모두 실패했습니다. 컴패니언 앱 실행 여부를 확인하세요.\n${notes.join("\n")}`, { isError: true });
|
|
1785
1790
|
}
|
|
1786
|
-
const payload = await
|
|
1787
|
-
body: { measuredAt: new Date().toISOString(), videos: specs },
|
|
1788
|
-
method: "POST",
|
|
1789
|
-
timeoutMs: 300_000,
|
|
1790
|
-
});
|
|
1791
|
+
const payload = await durable.run({ measuredAt: new Date().toISOString(), videos: specs });
|
|
1791
1792
|
if (payload?.ok === false || !payload?.fingerprint) {
|
|
1792
1793
|
return textResult(`지문 집계 실패: ${payload?.error || "알 수 없는 오류"}`, { isError: true });
|
|
1793
1794
|
}
|
|
1794
1795
|
return jsonResult({
|
|
1795
1796
|
fingerprint: payload.fingerprint,
|
|
1797
|
+
usage: payload.usage,
|
|
1798
|
+
accountCostEvents: payload.accountCostEvents,
|
|
1799
|
+
backgroundJobId: payload.backgroundJobId,
|
|
1800
|
+
...(payload.kind === "durable-channel-v2" ? { paidReplayAllowed: false } : {}),
|
|
1796
1801
|
next: "이 fingerprint 객체를 plan_shorts_video·stitch_timeline·write_script 의 channelFingerprint 로 그대로 넘기세요 — 길이·컷 리듬·말끝·자막 위치·BGM 이 이 규격을 따릅니다.",
|
|
1797
1802
|
notes: notes.length ? notes : undefined,
|
|
1798
1803
|
sampleCount: specs.length,
|
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.4";
|
|
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,30 @@
|
|
|
1
|
+
import { createHash, webcrypto } from 'node:crypto';
|
|
2
|
+
import { createDurableChannelRequest } from './durable-channel-request.mjs';
|
|
3
|
+
import { createChannelFileJournal } from './durable-channel-journal.mjs';
|
|
4
|
+
import { canonicalYoutubeWatchUrl } from './payload-guard.mjs';
|
|
5
|
+
import { matchesApiClientAuthority } from './api-client.mjs';
|
|
6
|
+
import { MCP_PROXY_VERSION } from './config.mjs';
|
|
7
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
export function createChannelMeasurementClient(config, api, videoUrls, { fetchImpl = globalThis.fetch, pollMs = 1000, timeoutMs = 240000 } = {}) {
|
|
9
|
+
const scope = new URL(config.apiBase).origin, pat = config.pat;
|
|
10
|
+
if (config.apiBase.replace(/\/$/u, '') !== scope || !['http:', 'https:'].includes(new URL(scope).protocol) || typeof pat !== 'string' || !pat) throw Error('DURABLE_CHANNEL_AUTHORITY_INVALID');
|
|
11
|
+
const urls = videoUrls.map(canonicalYoutubeWatchUrl);
|
|
12
|
+
if (!urls.length || urls.length > 5 || urls.some(url => !url)) throw Error('DURABLE_CHANNEL_VIDEO_URL_INVALID');
|
|
13
|
+
const fingerprint = hash(JSON.stringify([scope, pat])), projectId = `channel-measure:${hash(JSON.stringify(urls))}`, operation = 'channel-fingerprint', targetId = `durable-channel:${operation}:${projectId}`;
|
|
14
|
+
const assertCurrent = () => { if (config.pat !== pat || config.apiBase.replace(/\/$/u, '') !== scope || matchesApiClientAuthority(api, { apiBase: config.apiBase, pat }) === false) throw Error('DURABLE_CHANNEL_AUTHORITY_CHANGED'); return true; };
|
|
15
|
+
assertCurrent(); const journal = createChannelFileJournal({ stateDir: config.stateDir, authorityFingerprint: fingerprint });
|
|
16
|
+
const client = createDurableChannelRequest({ journal, apiOrigin: scope, fetchImpl, crypto: webcrypto, pollMs, timeoutMs,
|
|
17
|
+
getAuthority: () => { assertCurrent(); return { ownerUserId: fingerprint, sessionRevision: fingerprint }; },
|
|
18
|
+
getHeaders: () => { assertCurrent(); return { Authorization: `Bearer ${pat}`, 'X-AImakeAll-MCP-Version': MCP_PROXY_VERSION }; }, ensureSession: () => true });
|
|
19
|
+
const identity = { ownerUserId: fingerprint, apiOrigin: scope, projectId: `durable-channel:v1:${hash(JSON.stringify([operation, projectId, targetId]))}`, inputDigest: '0'.repeat(64) };
|
|
20
|
+
const options = { operation, projectId, targetId, assertCurrent, assertQueueReady: () => true,
|
|
21
|
+
applyResult: ({ recoveredPreviousRequest, ...result }) => journal.persistResult(identity, result, assertCurrent),
|
|
22
|
+
// The machine displays server receipts; it never increments a second ledger.
|
|
23
|
+
onAccountCost: result => { assertCurrent(); return result.accountingState === 'settled' && result.hasAccountCostEvents === true; } };
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
recover: () => client.run(null, { ...options, recoveryOnly: true }),
|
|
26
|
+
readCapability: () => client.readCapability(options),
|
|
27
|
+
run: body => client.run({ ...body, operation, projectId, targetId }, { ...options,
|
|
28
|
+
legacy: () => { assertCurrent(); return api.request('/api/tracker/gemini/channel-fingerprint', { body, method: 'POST', timeoutMs: 300000 }); } }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Closed browser/worker channel-analysis contract. Never accepts a provider,
|
|
2
|
+
// credential, arbitrary prompt, callback or model from the requester.
|
|
3
|
+
export const DURABLE_CHANNEL_CONTRACT_VERSION = 1;
|
|
4
|
+
export const DURABLE_CHANNEL_PATHS = Object.freeze({ 'channel-style': '/api/tracker/gemini/channel-style', 'channel-fingerprint': '/api/tracker/gemini/channel-fingerprint' });
|
|
5
|
+
export const DURABLE_CHANNEL_TYPES = Object.freeze(Object.fromEntries(Object.keys(DURABLE_CHANNEL_PATHS).map(key => [key, `durable-channel:${key}`])));
|
|
6
|
+
export const DURABLE_CHANNEL_MODEL = Object.freeze({ 'channel-style': 'gemini-2.5-flash-lite', 'channel-fingerprint': 'gemini-3-flash' });
|
|
7
|
+
export const DURABLE_CHANNEL_LIMITS = Object.freeze({ maxInputBytes: 16 * 1024 * 1024, maxOutputBytes: 1024 * 1024, maxProjectId: 160, maxTargetId: 240, maxStages: 24, maxVideos: 12, maxFrames: 8 });
|
|
8
|
+
export const DURABLE_CHANNEL_OUTPUT_FIELDS = Object.freeze(['operation', 'projectId', 'targetId', 'profile', 'analysis', 'fingerprint']);
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
function fail(output = false) { const code = `DURABLE_CHANNEL_${output ? 'OUTPUT' : 'INPUT'}_INVALID`; throw Object.assign(new Error(code), { code, statusCode: output ? 502 : 400 }); }
|
|
11
|
+
function record(value) { if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
|
|
12
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
13
|
+
if (Reflect.ownKeys(descriptors).some(key => typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) || !descriptors[key].enumerable || !('value' in descriptors[key]))) fail();
|
|
14
|
+
return Object.fromEntries(Object.entries(descriptors).map(([key, item]) => [key, item.value])); }
|
|
15
|
+
function copy(value, state, depth = 0) { if (++state.nodes > 40000 || depth > 16) fail();
|
|
16
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
17
|
+
if (typeof value === 'number') { if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) fail(); return value; }
|
|
18
|
+
if (typeof value === 'string') { if (!value.isWellFormed() || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) fail(); state.bytes += encoder.encode(value).byteLength; if (state.bytes > state.limit) fail(); return value; }
|
|
19
|
+
if (!value || typeof value !== 'object' || state.seen.has(value)) fail(); state.seen.add(value); let result;
|
|
20
|
+
if (Array.isArray(value)) { if (Object.getPrototypeOf(value) !== Array.prototype) fail(); const d = Object.getOwnPropertyDescriptors(value), size = d.length.value;
|
|
21
|
+
if (size > 2000 || Reflect.ownKeys(d).length !== size + 1) fail(); result = Array.from({ length: size }, (_, i) => { const item = d[i]; if (!item?.enumerable || !('value' in item)) fail(); return copy(item.value, state, depth + 1); });
|
|
22
|
+
} else result = Object.fromEntries(Object.entries(record(value)).map(([key, item]) => [key, copy(item, state, depth + 1)])); state.seen.delete(value); return Object.freeze(result); }
|
|
23
|
+
function capture(value, fields, limit) { const raw = record(value); if (Object.keys(raw).some(key => !fields.includes(key))) fail();
|
|
24
|
+
const out = copy(raw, { nodes: 0, bytes: 0, limit, seen: new WeakSet() }); if (encoder.encode(JSON.stringify(out)).byteLength > limit) fail(); return out; }
|
|
25
|
+
function identity(raw) { if (!Object.hasOwn(DURABLE_CHANNEL_TYPES, raw.operation)) fail();
|
|
26
|
+
for (const [key, max] of [['projectId', 160], ['targetId', 240]]) if (typeof raw[key] !== 'string' || !raw[key] || raw[key] !== raw[key].trim() || raw[key].length > max || /[\u0000-\u001f\u007f]/u.test(raw[key])) fail(); }
|
|
27
|
+
const VIDEO_FIELDS = ['videoId', 'durationSec', 'cutCount', 'loudnessDbfs', 'silenceRatio', 'transcriptSegments', 'cpsSample', 'subtitleYPct', 'subtitleLines', 'endingStyle', 'endingExample', 'emphasisCount', 'hasBgm', 'first3sText', 'aiCutCountGuess', 'aiDurationGuessSec', 'audioBase64', 'frames'];
|
|
28
|
+
const NUMBERS = ['durationSec', 'cutCount', 'loudnessDbfs', 'silenceRatio', 'cpsSample', 'subtitleYPct', 'subtitleLines', 'emphasisCount', 'aiCutCountGuess', 'aiDurationGuessSec'];
|
|
29
|
+
function analysis(value) { if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.channel?.title !== 'string' || !value.channel.title.trim() || value.channel.title.length > 1000) fail();
|
|
30
|
+
for (const key of ['topKeywords', 'highlights', 'videos']) if (value[key] !== undefined && !Array.isArray(value[key])) fail();
|
|
31
|
+
if (value.topKeywords?.some(v => typeof v !== 'string') || value.highlights?.some(v => !v || typeof v !== 'object') || value.videos?.some(v => !v || typeof v !== 'object')) fail(); }
|
|
32
|
+
export function captureDurableChannelInput(value) { const raw = record(value); identity(raw);
|
|
33
|
+
const body = capture(raw, ['operation', 'projectId', 'targetId', ...(raw.operation === 'channel-style' ? ['analysis'] : ['videos', 'measuredAt'])], DURABLE_CHANNEL_LIMITS.maxInputBytes);
|
|
34
|
+
if (body.operation === 'channel-style') { analysis(body.analysis); if (encoder.encode(JSON.stringify(body.analysis)).byteLength > 750000) fail(); }
|
|
35
|
+
else { if (!Array.isArray(body.videos) || !body.videos.length || body.videos.length > 12 || typeof body.measuredAt !== 'string' || !Number.isFinite(Date.parse(body.measuredAt)) || new Date(body.measuredAt).toISOString() !== body.measuredAt) fail();
|
|
36
|
+
for (const v of body.videos) { if (!v || Array.isArray(v) || Object.keys(v).some(k => !VIDEO_FIELDS.includes(k))) fail();
|
|
37
|
+
for (const k of NUMBERS) if (v[k] !== undefined && v[k] !== null && (typeof v[k] !== 'number' || Math.abs(v[k]) > 1e9)) fail();
|
|
38
|
+
for (const k of ['videoId', 'endingStyle', 'endingExample', 'first3sText']) if (v[k] != null && (typeof v[k] !== 'string' || v[k].length > 10000)) fail();
|
|
39
|
+
if (v.hasBgm != null && typeof v.hasBgm !== 'boolean') fail();
|
|
40
|
+
if (v.audioBase64 !== undefined && (typeof v.audioBase64 !== 'string' || v.audioBase64.length > 7000000 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(v.audioBase64))) fail();
|
|
41
|
+
if (v.transcriptSegments !== undefined && (!Array.isArray(v.transcriptSegments) || v.transcriptSegments.some(s => !s || typeof s !== 'object' || Array.isArray(s)))) fail();
|
|
42
|
+
if (v.frames !== undefined && (!Array.isArray(v.frames) || v.frames.length > 8 || v.frames.some(f => typeof f !== 'string' || !/^data:image\/(?:png|jpeg|webp);base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(f) || f.endsWith(',')))) fail();
|
|
43
|
+
}
|
|
44
|
+
} return Object.freeze({ body, jobType: DURABLE_CHANNEL_TYPES[body.operation] }); }
|
|
45
|
+
export function captureDurableChannelOutput(value) { try { const raw = capture(value, DURABLE_CHANNEL_OUTPUT_FIELDS, DURABLE_CHANNEL_LIMITS.maxOutputBytes); identity(raw);
|
|
46
|
+
if (raw.operation === 'channel-style') { analysis(raw.analysis); if (!raw.profile || Array.isArray(raw.profile) || raw.fingerprint !== undefined) fail(true);
|
|
47
|
+
const strings = ['channelTitle', 'introPattern', 'outroPattern', 'pacing', 'recommendedFormat', 'recommendedStyleId', 'structurePattern', 'summary', 'toneGuide', 'toneLabel'];
|
|
48
|
+
if (Object.keys(raw.profile).length !== strings.length + 2 || strings.some(k => typeof raw.profile[k] !== 'string') || !raw.profile.summary.trim()) fail(true);
|
|
49
|
+
for (const [k, max] of [['referenceKeywords', 6], ['scriptDirectives', 5]]) if (!Array.isArray(raw.profile[k]) || raw.profile[k].length > max || raw.profile[k].some(v => typeof v !== 'string')) fail(true);
|
|
50
|
+
} else {
|
|
51
|
+
const f = raw.fingerprint, numeric = ['durationTargetSec', 'durationIqrSec', 'cutIntervalSec', 'cutIntervalIqrSec', 'cutsPerMin', 'loudnessDbfs', 'silenceRatio', 'cpsTarget', 'subtitleYPct', 'subtitleYIqr', 'subtitleLines', 'emphasisCount', 'consistencyScore'];
|
|
52
|
+
const fields = ['sampleSize', 'measuredAt', 'schemaVersion', ...numeric, 'endingStyle', 'endingExamples', 'hasBgm', 'firstHooks', 'provenance', 'crossCheckFlags'];
|
|
53
|
+
if (!f || typeof f !== 'object' || Array.isArray(f) || raw.profile !== undefined || raw.analysis !== undefined || Object.keys(f).length !== fields.length || Object.keys(f).some(k => !fields.includes(k))
|
|
54
|
+
|| f.schemaVersion !== 1 || !Number.isInteger(f.sampleSize) || f.sampleSize < 1 || f.sampleSize > 12 || typeof f.measuredAt !== 'string' || !Number.isFinite(Date.parse(f.measuredAt))
|
|
55
|
+
|| numeric.some(k => f[k] !== null && typeof f[k] !== 'number') || f.endingStyle !== null && typeof f.endingStyle !== 'string' || f.hasBgm !== null && typeof f.hasBgm !== 'boolean'
|
|
56
|
+
|| !Array.isArray(f.crossCheckFlags) || f.crossCheckFlags.length > 24 || !f.provenance || Array.isArray(f.provenance)) fail(true);
|
|
57
|
+
for (const key of ['endingExamples', 'firstHooks']) if (!Array.isArray(f[key]) || f[key].length > 3 || f[key].some(v => typeof v !== 'string')) fail(true);
|
|
58
|
+
}
|
|
59
|
+
return raw;
|
|
60
|
+
} catch { fail(true); } }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, fstatSync, lstatSync, mkdirSync, readFileSync, writeFileSync, fsyncSync, readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
5
|
+
const fail = () => { throw Object.assign(new Error('채널 분석 기록을 확인하지 못했습니다. 자동 재제출하지 않습니다.'), { code: 'DURABLE_CHANNEL_JOURNAL_UNAVAILABLE', paidReplayAllowed: false }); };
|
|
6
|
+
// Immutable files with exclusive creation. No truncation, overwrite, stale-lock
|
|
7
|
+
// removal, expiry, TTL or ambiguous-write replay; never stores a PAT or media.
|
|
8
|
+
export function createChannelFileJournal({ stateDir, authorityFingerprint }) {
|
|
9
|
+
if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir) || !/^[a-f0-9]{64}$/u.test(authorityFingerprint || '')) fail();
|
|
10
|
+
const handles = new WeakMap(), root = path.join(stateDir, 'channel-analysis-v1');
|
|
11
|
+
function directory(dir, create = false, privateOnly = true) {
|
|
12
|
+
if (create) { try { mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') fail(); } }
|
|
13
|
+
const st = lstatSync(dir); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || st.mode & (privateOnly ? 0o077 : 0o022)) fail();
|
|
14
|
+
}
|
|
15
|
+
// Older clients created the shared state directory with 0755. It contains
|
|
16
|
+
// no journal data directly and must be owner-only writable. The dedicated
|
|
17
|
+
// journal and every record remain strictly 0700, never symlink-followed.
|
|
18
|
+
try { directory(stateDir, true, false); directory(root, true); } catch { fail(); }
|
|
19
|
+
function read(file, max = 32768) { let fd; try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); const st = fstatSync(fd);
|
|
20
|
+
if (!st.isFile() || st.nlink !== 1 || st.uid !== process.getuid() || st.mode & 0o077 || st.size > max || st.size < 2) fail();
|
|
21
|
+
return JSON.parse(readFileSync(fd, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; fail(); } finally { if (fd !== undefined) closeSync(fd); } }
|
|
22
|
+
function exclusive(file, value, max = 32768) { const bytes = JSON.stringify(value); if (Buffer.byteLength(bytes) > max) fail(); let fd;
|
|
23
|
+
try { fd = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); writeFileSync(fd, bytes); fsyncSync(fd); }
|
|
24
|
+
catch (e) { if (e.code !== 'EEXIST') fail(); } finally { if (fd !== undefined) closeSync(fd); }
|
|
25
|
+
const parent = openSync(path.dirname(file), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
26
|
+
if (JSON.stringify(read(file, max)) !== bytes) fail();
|
|
27
|
+
}
|
|
28
|
+
function location(identity) { if (!identity || Object.keys(identity).sort().join(',') !== 'apiOrigin,inputDigest,ownerUserId,projectId' || !/^[a-f0-9]{64}$/u.test(identity.inputDigest) || typeof identity.projectId !== 'string') fail();
|
|
29
|
+
return path.join(root, hash(JSON.stringify([identity.apiOrigin, identity.projectId]))); }
|
|
30
|
+
function loaded(identity) { const dir = location(identity); let entry; try { directory(dir); entry = read(path.join(dir, 'intent.json')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
31
|
+
if (!entry || entry.authorityFingerprint !== authorityFingerprint || entry.record?.version !== 1 || ['ownerUserId', 'apiOrigin', 'projectId'].some(k => entry.record[k] !== identity[k])) fail();
|
|
32
|
+
const accepted = read(path.join(dir, 'accepted.json')), terminal = read(path.join(dir, 'terminal.json'));
|
|
33
|
+
if (accepted && (!/^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.test(accepted.jobId) || accepted.authorityFingerprint !== authorityFingerprint)) fail();
|
|
34
|
+
if (terminal && (!['completed', 'failed', 'unknown'].includes(terminal.state) || !accepted || terminal.jobId !== accepted.jobId)) fail();
|
|
35
|
+
return { dir, record: { ...entry.record, jobId: accepted?.jobId || '', state: terminal?.state || (accepted ? 'submitted' : 'unknown') } };
|
|
36
|
+
}
|
|
37
|
+
function envelope(value, check, canSubmit = false) { check(); const handle = Object.freeze({}); handles.set(handle, { ...value, check }); return { handle, record: value.record, canSubmit }; }
|
|
38
|
+
const state = handle => { const s = handles.get(handle); if (!s) fail(); s.check(); directory(root); directory(s.dir); return s; };
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
async lookup(identity, { assertCurrent }) { assertCurrent(); directory(root); const value = loaded(identity); return value ? envelope(value, assertCurrent) : null; },
|
|
41
|
+
async claim(identity, { assertCurrent }) { assertCurrent(); directory(root); const existing = loaded(identity); if (existing) return envelope(existing, assertCurrent);
|
|
42
|
+
// A full journal is recoverable, never silently pruned to make room.
|
|
43
|
+
if (readdirSync(root).length >= 32) fail(); const dir = location(identity);
|
|
44
|
+
try { mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') fail(); const raced = loaded(identity); if (!raced) fail(); return envelope(raced, assertCurrent); }
|
|
45
|
+
if (readdirSync(root).length > 32) fail();
|
|
46
|
+
const record = { version: 1, ...identity, localSubmissionId: randomUUID(), createdAt: Date.now(), state: 'submitted', jobId: '' };
|
|
47
|
+
assertCurrent(); exclusive(path.join(dir, 'intent.json'), { authorityFingerprint, record }); assertCurrent(); return envelope({ dir, record }, assertCurrent, true);
|
|
48
|
+
},
|
|
49
|
+
async rememberJob(handle, jobId) { const s = state(handle); if (!/^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.test(jobId || '')) fail(); exclusive(path.join(s.dir, 'accepted.json'), { authorityFingerprint, jobId }); s.record = { ...s.record, jobId }; s.check(); },
|
|
50
|
+
async markResult(handle, { state: status }) { const s = state(handle); if (!s.record.jobId || !['unknown', 'completed', 'failed'].includes(status)) fail(); exclusive(path.join(s.dir, 'terminal.json'), { jobId: s.record.jobId, state: status }); s.check(); },
|
|
51
|
+
async persistResult(identity, result, check) { check(); const value = loaded(identity); if (!value || value.record.jobId !== result.backgroundJobId) fail(); exclusive(path.join(value.dir, 'result.json'), result, 1200000); check(); return true; },
|
|
52
|
+
});
|
|
53
|
+
}
|