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
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { captureDurableChannelInput, captureDurableChannelOutput, DURABLE_CHANNEL_CONTRACT_VERSION,
|
|
2
|
+
DURABLE_CHANNEL_MODEL, DURABLE_CHANNEL_LIMITS, DURABLE_CHANNEL_TYPES, DURABLE_CHANNEL_OUTPUT_FIELDS, DURABLE_CHANNEL_PATHS } from './durable-channel-contract.mjs';
|
|
3
|
+
|
|
4
|
+
export const durableChannelPath = operation => DURABLE_CHANNEL_PATHS[operation];
|
|
5
|
+
export const durableChannelCapabilityPath = operation => `${durableChannelPath(operation)}/execution-capability`;
|
|
6
|
+
export const DURABLE_CHANNEL_REQUEST_LIMITS = Object.freeze({ maxResponseBytes: DURABLE_CHANNEL_LIMITS.maxOutputBytes + 65536, maxReadChunks: 65536 });
|
|
7
|
+
const 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;
|
|
8
|
+
const COST = /^bg-durable-channel-v1:[a-f0-9]{64}:(?:[1-9]|1[0-9]|2[0-4])$/u;
|
|
9
|
+
const encoder = new TextEncoder(), failures = new WeakMap();
|
|
10
|
+
const messages = Object.freeze({
|
|
11
|
+
INVALID: '채널 분석 요청을 안전하게 확인하지 못했습니다.',
|
|
12
|
+
AUTHORITY_CHANGED: '계정·프로젝트·입력이 변경되어 이전 채널 분석 작업을 중단했습니다.',
|
|
13
|
+
SESSION_REQUIRED: '로그인 후 채널 분석를 생성해 주세요.',
|
|
14
|
+
UNAVAILABLE: '현재 서버에서는 안전한 채널 분석 생성을 사용할 수 없습니다.',
|
|
15
|
+
RESPONSE_INVALID: '채널 분석 응답을 안전하게 확인하지 못했습니다. 자동 재생성하지 않습니다.',
|
|
16
|
+
JOURNAL_UNAVAILABLE: '제출 기록을 확인하지 못했습니다. 자동 재생성하지 않습니다.',
|
|
17
|
+
JOURNAL_FULL: '안전한 제출 기록 한도(계정당 32건)에 도달했습니다. 기존 작업 조회는 가능하지만 새 생성은 차단됩니다. 기록을 삭제하지 말고 고객지원에 문의해 주세요.',
|
|
18
|
+
UNRESOLVED: '이전 제출 결과가 확정되지 않았습니다. 자동 재생성하지 않습니다.',
|
|
19
|
+
MISSING: '이전 작업을 찾을 수 없습니다. 새 생성 요청을 보내지 않았습니다.',
|
|
20
|
+
WAIT_ABORTED: '기다리기를 중단했습니다. 생성 취소가 확인된 것은 아닙니다.',
|
|
21
|
+
WAIT_TIMEOUT: '대기 시간이 초과되었습니다. 기존 작업만 다시 조회할 수 있습니다.',
|
|
22
|
+
DIFFERENT_EDITOR: '이 프로젝트에 다른 제작 화면의 미확정 작업이 있습니다. 원래 화면에서 먼저 기존 작업을 조회해 주세요.',
|
|
23
|
+
APPLY_UNCONFIRMED: '작업 결과는 완료됐지만 프로젝트 저장을 확인하지 못했습니다. 다음에 기존 작업만 다시 조회합니다.',
|
|
24
|
+
ACTIVE: '채널 분석 작업이 이미 진행 중입니다.',
|
|
25
|
+
});
|
|
26
|
+
function failure(kind) {
|
|
27
|
+
const error = Object.assign(new Error(messages[kind] || messages.INVALID), {
|
|
28
|
+
code: `DURABLE_CHANNEL_${kind}`, paidReplayAllowed: false,
|
|
29
|
+
}); failures.set(error, kind); return error;
|
|
30
|
+
}
|
|
31
|
+
const fail = kind => { throw failure(kind); };
|
|
32
|
+
const own = (value, key) => value && typeof value === 'object' ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
33
|
+
// Legacy transport already projects usage; copy only its bounded public fields.
|
|
34
|
+
// Never copy an error message, request, headers, arbitrary object or credential.
|
|
35
|
+
function legacyUsage(value, depth = 0) {
|
|
36
|
+
if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return undefined;
|
|
37
|
+
const output = {};
|
|
38
|
+
for (const key of ['aiCalls', 'costUsd', 'inputTokens', 'outputTokens', 'totalTokens', 'credits', 'creditsConsumed']) {
|
|
39
|
+
const number = own(value, key);
|
|
40
|
+
if (typeof number === 'number' && Number.isFinite(number) && number >= 0 && number <= Number.MAX_SAFE_INTEGER) output[key] = number;
|
|
41
|
+
}
|
|
42
|
+
for (const key of ['model', 'provider', 'stage']) {
|
|
43
|
+
const item = own(value, key); if (text(item, 160)) output[key] = item;
|
|
44
|
+
}
|
|
45
|
+
const stages = own(value, 'stages');
|
|
46
|
+
if (depth === 0 && Array.isArray(stages) && stages.length > 0 && stages.length <= 24) {
|
|
47
|
+
output.stages = Array.from({ length: stages.length }, (_, i) => legacyUsage(own(stages, String(i)), 1)).filter(Boolean);
|
|
48
|
+
if (!output.stages.length) delete output.stages;
|
|
49
|
+
}
|
|
50
|
+
return Object.keys(output).length ? Object.freeze(output) : undefined;
|
|
51
|
+
}
|
|
52
|
+
function object(value) {
|
|
53
|
+
if (!value || typeof value !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail('RESPONSE_INVALID');
|
|
54
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
55
|
+
if (Reflect.ownKeys(descriptors).some(key => typeof key !== 'string' || !('value' in descriptors[key]) || !descriptors[key].enumerable)) fail('RESPONSE_INVALID');
|
|
56
|
+
return Object.fromEntries(Object.entries(descriptors).map(([key, item]) => [key, item.value]));
|
|
57
|
+
}
|
|
58
|
+
function text(value, maximum = 255) {
|
|
59
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maximum && value.trim() === value
|
|
60
|
+
&& value.isWellFormed() && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
61
|
+
}
|
|
62
|
+
function origin(value) {
|
|
63
|
+
if (!text(value, 2048)) fail('INVALID');
|
|
64
|
+
let url; try { url = new URL(value); } catch { fail('INVALID'); }
|
|
65
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/'
|
|
66
|
+
|| ![url.origin, `${url.origin}/`].includes(value)) fail('INVALID');
|
|
67
|
+
return url.origin;
|
|
68
|
+
}
|
|
69
|
+
function authority(getAuthority) {
|
|
70
|
+
const value = getAuthority(), ownerUserId = own(value, 'ownerUserId'), sessionRevision = own(value, 'sessionRevision');
|
|
71
|
+
if (!text(ownerUserId) || ownerUserId.startsWith('deleted:')
|
|
72
|
+
|| !(text(sessionRevision, 512) || Number.isSafeInteger(sessionRevision) && sessionRevision >= 0)) fail('SESSION_REQUIRED');
|
|
73
|
+
return Object.freeze({ ownerUserId, sessionRevision });
|
|
74
|
+
}
|
|
75
|
+
function synchronous(callback) {
|
|
76
|
+
const result = callback();
|
|
77
|
+
if (result && typeof result.then === 'function') { Promise.resolve(result).catch(() => {}); fail('AUTHORITY_CHANGED'); }
|
|
78
|
+
if (result === false) fail('AUTHORITY_CHANGED');
|
|
79
|
+
}
|
|
80
|
+
function exactConstants(value, expected) {
|
|
81
|
+
const captured = object(value), keys = Object.keys(expected);
|
|
82
|
+
if (Object.keys(captured).length !== keys.length || keys.some(key => captured[key] !== expected[key])) fail('RESPONSE_INVALID');
|
|
83
|
+
}
|
|
84
|
+
export function validateDurableChannelCapability(value, operation) {
|
|
85
|
+
const captured = object(value);
|
|
86
|
+
if (captured.version !== 1 || captured.contractVersion !== DURABLE_CHANNEL_CONTRACT_VERSION || captured.operation !== operation
|
|
87
|
+
|| !['legacy-sync', 'queue-v2', 'unavailable'].includes(captured.mode)) fail('RESPONSE_INVALID');
|
|
88
|
+
exactConstants(captured.model, DURABLE_CHANNEL_MODEL); exactConstants(captured.limits, DURABLE_CHANNEL_LIMITS);
|
|
89
|
+
const allowed = ['version', 'contractVersion', 'operation', 'mode', 'model', 'limits', ...(captured.mode === 'queue-v2'
|
|
90
|
+
? ['paidReplayAllowed', 'recovery'] : captured.mode === 'unavailable' ? ['reason'] : [])];
|
|
91
|
+
if (Object.keys(captured).some(key => !allowed.includes(key)) || captured.mode === 'queue-v2'
|
|
92
|
+
&& (captured.paidReplayAllowed !== false || captured.recovery !== 'read-only-known-job')
|
|
93
|
+
|| captured.reason !== undefined && !text(captured.reason, 128)) fail('RESPONSE_INVALID');
|
|
94
|
+
return captured.mode;
|
|
95
|
+
}
|
|
96
|
+
function captureUsage(value, provider, model, { total = false } = {}) {
|
|
97
|
+
const item = object(value);
|
|
98
|
+
if (item.aiCalls !== 1 || !Number.isSafeInteger(item.inputTokens) || item.inputTokens < 0
|
|
99
|
+
|| !Number.isSafeInteger(item.outputTokens) || item.outputTokens < 0
|
|
100
|
+
|| !Number.isSafeInteger(item.inputTokens + item.outputTokens)
|
|
101
|
+
|| typeof item.costUsd !== 'number' || !Number.isFinite(item.costUsd) || item.costUsd < 0
|
|
102
|
+
|| item.model !== undefined && item.model !== model
|
|
103
|
+
|| item.provider !== undefined && item.provider !== provider
|
|
104
|
+
|| (total || item.totalTokens !== undefined) && item.totalTokens !== item.inputTokens + item.outputTokens) fail('RESPONSE_INVALID');
|
|
105
|
+
return Object.freeze({ aiCalls: 1, inputTokens: item.inputTokens, outputTokens: item.outputTokens, costUsd: item.costUsd,
|
|
106
|
+
...(item.totalTokens === undefined ? {} : { totalTokens: item.totalTokens }),
|
|
107
|
+
...(item.model === undefined ? {} : { model: item.model }), ...(item.provider === undefined ? {} : { provider: item.provider }) });
|
|
108
|
+
}
|
|
109
|
+
export function validateCompletedDurableChannelJob(value, id) {
|
|
110
|
+
const job = object(value);
|
|
111
|
+
if (!JOB.test(id) || job.id !== id || !Object.values(DURABLE_CHANNEL_TYPES).includes(job.type) || job.status !== 'completed') fail('RESPONSE_INVALID');
|
|
112
|
+
const result = object(job.result), model = DURABLE_CHANNEL_MODEL[result.operation];
|
|
113
|
+
if (DURABLE_CHANNEL_TYPES[result.operation] !== job.type || result.kind !== 'durable-channel-v2'
|
|
114
|
+
|| result.model !== model
|
|
115
|
+
|| result.accountingState !== 'settled' || result.hasAccountCostEvents !== true
|
|
116
|
+
|| !Array.isArray(result.accountCostEvents) || result.accountCostEvents.length > DURABLE_CHANNEL_LIMITS.maxStages) fail('RESPONSE_INVALID');
|
|
117
|
+
const count = result.accountCostEvents.length;
|
|
118
|
+
if (result.operation === 'channel-style' && (count < 1 || count > 2)
|
|
119
|
+
|| count > 0 && result.provider !== 'evolink'
|
|
120
|
+
|| (count ? result.provider !== 'evolink' : result.provider !== 'none')) fail('RESPONSE_INVALID');
|
|
121
|
+
let output; try { output = captureDurableChannelOutput(Object.fromEntries(DURABLE_CHANNEL_OUTPUT_FIELDS
|
|
122
|
+
.filter(key => result[key] !== undefined).map(key => [key, result[key]]))); } catch { fail('RESPONSE_INVALID'); }
|
|
123
|
+
const ids = new Set();
|
|
124
|
+
const receipts = result.accountCostEvents.map(raw => {
|
|
125
|
+
const event = object(raw), provider = event.provider;
|
|
126
|
+
const committed = event.accountCostRecorded === true && event.accountCostSuppressedByReset !== true;
|
|
127
|
+
const suppressed = event.accountCostRecorded === false && event.accountCostSuppressedByReset === true;
|
|
128
|
+
if (!COST.test(event.accountCostEventId || '') || ids.has(event.accountCostEventId) || !(committed || suppressed)
|
|
129
|
+
|| event.accountCostSuppressedByReset !== undefined && typeof event.accountCostSuppressedByReset !== 'boolean'
|
|
130
|
+
|| event.model !== model || provider !== result.provider || !Number.isSafeInteger(event.createdAt)
|
|
131
|
+
|| event.createdAt <= 0 || event.createdAt > 253402300799999 || typeof event.costUsd !== 'number'
|
|
132
|
+
|| !Number.isFinite(event.costUsd) || event.costUsd < 0) fail('RESPONSE_INVALID');
|
|
133
|
+
ids.add(event.accountCostEventId);
|
|
134
|
+
const usage = captureUsage(event.usage, provider, model);
|
|
135
|
+
if (usage.costUsd !== event.costUsd) fail('RESPONSE_INVALID');
|
|
136
|
+
return Object.freeze({ accountCostEventId: event.accountCostEventId, createdAt: event.createdAt,
|
|
137
|
+
provider, model, costUsd: event.costUsd, accountCostRecorded: committed,
|
|
138
|
+
...(suppressed ? { accountCostSuppressedByReset: true } : {}), usage });
|
|
139
|
+
});
|
|
140
|
+
const total = object(result.usage), sum = key => receipts.reduce((n, item) => n + item.usage[key], 0);
|
|
141
|
+
if (total.aiCalls !== receipts.length || total.inputTokens !== sum('inputTokens') || total.outputTokens !== sum('outputTokens')
|
|
142
|
+
|| total.totalTokens !== total.inputTokens + total.outputTokens || !Number.isSafeInteger(total.totalTokens)
|
|
143
|
+
|| typeof total.costUsd !== 'number' || !Number.isFinite(total.costUsd) || total.costUsd < 0
|
|
144
|
+
|| Math.abs(total.costUsd - sum('costUsd')) > 0.000001) fail('RESPONSE_INVALID');
|
|
145
|
+
const usage = Object.freeze({ aiCalls: total.aiCalls, inputTokens: total.inputTokens, outputTokens: total.outputTokens,
|
|
146
|
+
totalTokens: total.totalTokens, costUsd: total.costUsd });
|
|
147
|
+
return Object.freeze({ ...output, ok: true, kind: 'durable-channel-v2', model, provider: result.provider, usage, hasAccountCostEvents: true,
|
|
148
|
+
accountingState: 'settled', accountCostEvents: Object.freeze(receipts), backgroundJobId: id });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Strict stream EOF, fatal UTF-8 and declared-byte checks. Abort starts reader
|
|
152
|
+
// cancellation immediately, but does not detach an admitted read/cancel promise.
|
|
153
|
+
async function readJson(response, { check, signal, url }) {
|
|
154
|
+
let reader, cancellation, complete = false;
|
|
155
|
+
const cancel = () => {
|
|
156
|
+
if (cancellation || complete) return;
|
|
157
|
+
try { cancellation = Promise.resolve(reader ? reader.cancel() : response?.body?.cancel()).catch(() => {}); }
|
|
158
|
+
catch { cancellation = Promise.resolve(); }
|
|
159
|
+
};
|
|
160
|
+
const abort = () => cancel();
|
|
161
|
+
try {
|
|
162
|
+
check();
|
|
163
|
+
if (!response || response.redirected || response.type === 'opaqueredirect' || response.type === 'opaque'
|
|
164
|
+
|| response.url && response.url !== url
|
|
165
|
+
|| !Number.isInteger(response.status) || typeof response.body?.getReader !== 'function') fail('RESPONSE_INVALID');
|
|
166
|
+
const mime = (response.headers.get('content-type') || '').split(';', 1)[0].trim().toLowerCase();
|
|
167
|
+
if (mime !== 'application/json') fail('RESPONSE_INVALID');
|
|
168
|
+
const length = response.headers.get('content-length'), encoding = response.headers.get('content-encoding');
|
|
169
|
+
if (length !== null && (!/^(0|[1-9][0-9]*)$/u.test(length) || !Number.isSafeInteger(Number(length))
|
|
170
|
+
|| Number(length) > DURABLE_CHANNEL_REQUEST_LIMITS.maxResponseBytes)) fail('RESPONSE_INVALID');
|
|
171
|
+
reader = response.body.getReader(); signal.addEventListener('abort', abort, { once: true });
|
|
172
|
+
if (signal.aborted) { cancel(); check(); }
|
|
173
|
+
const decoder = new TextDecoder('utf-8', { fatal: true }), blocks = []; let bytes = 0, chunks = 0;
|
|
174
|
+
for (;;) {
|
|
175
|
+
check(); const part = await reader.read(); check();
|
|
176
|
+
if (part.done) break;
|
|
177
|
+
if (!(part.value instanceof Uint8Array) || ++chunks > DURABLE_CHANNEL_REQUEST_LIMITS.maxReadChunks
|
|
178
|
+
|| (bytes += part.value.byteLength) > DURABLE_CHANNEL_REQUEST_LIMITS.maxResponseBytes) fail('RESPONSE_INVALID');
|
|
179
|
+
blocks.push(decoder.decode(part.value, { stream: true }));
|
|
180
|
+
}
|
|
181
|
+
if (!bytes || length !== null && (!encoding || encoding === 'identity') && Number(length) !== bytes) fail('RESPONSE_INVALID');
|
|
182
|
+
blocks.push(decoder.decode()); const value = JSON.parse(blocks.join('')); check(); complete = true;
|
|
183
|
+
return { status: response.status, body: value };
|
|
184
|
+
} catch (error) { cancel(); throw failures.has(error) ? error : failure('RESPONSE_INVALID'); }
|
|
185
|
+
finally { signal.removeEventListener('abort', abort); await cancellation; reader?.releaseLock(); }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A journal intent is consumed before the one paid POST. Restored intentions
|
|
189
|
+
* with an ID can only GET it; no ID, a missing job, failure or mode downgrade
|
|
190
|
+
* never permits another POST or the legacy callback. Browser storage loss is
|
|
191
|
+
* not server-side idempotency; this API offers no reset/new-intent escape. */
|
|
192
|
+
export function createDurableChannelRequest({ journal, apiOrigin, fetchImpl = globalThis.fetch, getAuthority,
|
|
193
|
+
getHeaders, ensureSession, crypto = globalThis.crypto, pollMs = 1000, timeoutMs = 240000 } = {}) {
|
|
194
|
+
const base = origin(apiOrigin), digest = typeof crypto?.subtle?.digest === 'function' ? crypto.subtle.digest.bind(crypto.subtle) : null;
|
|
195
|
+
if ([fetchImpl, getAuthority, getHeaders, ensureSession, ...['lookup', 'claim', 'rememberJob', 'markResult'].map(key => journal?.[key])]
|
|
196
|
+
.some(fn => typeof fn !== 'function') || !digest || !Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 10000
|
|
197
|
+
|| !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 900000) throw failure('INVALID');
|
|
198
|
+
const storage = Object.fromEntries(['lookup', 'claim', 'rememberJob', 'markResult'].map(key => [key, journal[key].bind(journal)]));
|
|
199
|
+
let active = false;
|
|
200
|
+
return Object.freeze({ async readCapability({ operation, assertCurrent, signal, assertQueueReady } = {}) {
|
|
201
|
+
if (!Object.hasOwn(DURABLE_CHANNEL_TYPES, operation) || typeof assertCurrent !== 'function'
|
|
202
|
+
|| signal !== undefined && !(signal instanceof AbortSignal)) fail('INVALID');
|
|
203
|
+
const captured = authority(getAuthority), controller = new AbortController();
|
|
204
|
+
const abort = () => controller.abort(failure('WAIT_ABORTED'));
|
|
205
|
+
const timer = setTimeout(() => controller.abort(failure('WAIT_TIMEOUT')), Math.min(timeoutMs, 15000));
|
|
206
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
|
|
207
|
+
const check = () => { const current = authority(getAuthority); synchronous(assertCurrent);
|
|
208
|
+
if (current.ownerUserId !== captured.ownerUserId || current.sessionRevision !== captured.sessionRevision) fail('AUTHORITY_CHANGED');
|
|
209
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
check(); if (await ensureSession() === false) fail('SESSION_REQUIRED'); check();
|
|
213
|
+
const headers = new Headers(await getHeaders()); headers.set('Accept', 'application/json'); check();
|
|
214
|
+
const url = `${base}${durableChannelCapabilityPath(operation)}`;
|
|
215
|
+
const response = await fetchImpl(url, { method: 'GET', headers, credentials: 'include', redirect: 'error', cache: 'no-store', signal: controller.signal });
|
|
216
|
+
const reply = await readJson(response, { check, signal: controller.signal, url }); check();
|
|
217
|
+
if (reply.status !== 200) fail('UNAVAILABLE');
|
|
218
|
+
const mode = validateDurableChannelCapability(reply.body, operation);
|
|
219
|
+
if (mode === 'unavailable') fail('UNAVAILABLE');
|
|
220
|
+
if (mode === 'queue-v2') {
|
|
221
|
+
if (typeof assertQueueReady !== 'function' || await assertQueueReady() !== true) fail('INVALID'); check();
|
|
222
|
+
}
|
|
223
|
+
return mode;
|
|
224
|
+
} catch (caught) { throw failures.has(caught) ? caught : failure('RESPONSE_INVALID'); }
|
|
225
|
+
finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
|
|
226
|
+
}, async run(rawBody, { projectId, assertCurrent, signal, onState = () => {}, onAccountCost = () => {}, legacy,
|
|
227
|
+
recoveryOnly = false, operation, targetId, applyResult, assertQueueReady } = {}) {
|
|
228
|
+
if (active) throw failure('ACTIVE');
|
|
229
|
+
if (!Object.hasOwn(DURABLE_CHANNEL_TYPES, operation) || !text(targetId, 240) || !text(projectId, 160) || [assertCurrent, onState, onAccountCost].some(fn => typeof fn !== 'function')
|
|
230
|
+
|| signal !== undefined && !(signal instanceof AbortSignal) || typeof recoveryOnly !== 'boolean') throw failure('INVALID');
|
|
231
|
+
let body;
|
|
232
|
+
// Legacy accepts its original inputs, which may exceed the new queue's
|
|
233
|
+
// bounded contract. Defer only this v2 validation error until transport
|
|
234
|
+
// selection; even invalid edited inputs must first recover pending work.
|
|
235
|
+
if (!recoveryOnly) { try { body = captureDurableChannelInput(rawBody).body; if (body.operation !== operation || body.targetId !== targetId || body.projectId !== projectId) body = undefined; } catch { body = undefined; } }
|
|
236
|
+
const captured = authority(getAuthority); active = true;
|
|
237
|
+
const controller = new AbortController(), deadline = performance.now() + timeoutMs;
|
|
238
|
+
const abort = () => controller.abort(failure('WAIT_ABORTED'));
|
|
239
|
+
const timer = setTimeout(() => controller.abort(failure('WAIT_TIMEOUT')), timeoutMs);
|
|
240
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
|
|
241
|
+
let jobId = null, status = 'checking', handle, claimStarted = false, legacyStarted = false, recovering = false;
|
|
242
|
+
const check = () => {
|
|
243
|
+
let current; try { current = authority(getAuthority); synchronous(assertCurrent); } catch { fail('AUTHORITY_CHANGED'); }
|
|
244
|
+
if (current.ownerUserId !== captured.ownerUserId || current.sessionRevision !== captured.sessionRevision) fail('AUTHORITY_CHANGED');
|
|
245
|
+
if (!controller.signal.aborted && performance.now() >= deadline) controller.abort(failure('WAIT_TIMEOUT'));
|
|
246
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
247
|
+
};
|
|
248
|
+
const checked = async operation => { check(); const value = await operation(); check(); return value; };
|
|
249
|
+
const publish = next => { check(); status = next; try { Promise.resolve(onState(Object.freeze({ version: 1, status,
|
|
250
|
+
jobId, recovering, paidReplayAllowed: false }))).catch(() => {}); } catch { /* UI cannot authorize a replay. */ } };
|
|
251
|
+
const persist = async operation => { try { return await checked(operation); } catch (error) {
|
|
252
|
+
check(); throw failures.has(error) ? error : failure(own(error, 'code') === 'UPLOAD_METADATA_JOURNAL_FULL' ? 'JOURNAL_FULL' : 'JOURNAL_UNAVAILABLE');
|
|
253
|
+
} };
|
|
254
|
+
async function request(path, method = 'GET') {
|
|
255
|
+
if (recoveryOnly && method !== 'GET') fail('INVALID');
|
|
256
|
+
const rawHeaders = await checked(() => getHeaders()), headers = new Headers(rawHeaders);
|
|
257
|
+
headers.set('Accept', 'application/json'); if (method === 'POST') headers.set('Content-Type', 'application/json');
|
|
258
|
+
let response;
|
|
259
|
+
try {
|
|
260
|
+
check(); response = await fetchImpl(`${base}${path}`, { method, headers, credentials: 'include', redirect: 'error',
|
|
261
|
+
cache: 'no-store', signal: controller.signal, ...(method === 'POST' ? { body: JSON.stringify(body) } : {}) });
|
|
262
|
+
return await readJson(response, { check, signal: controller.signal, url: `${base}${path}` });
|
|
263
|
+
} catch (error) { check(); throw failures.has(error) ? error : failure('RESPONSE_INVALID'); }
|
|
264
|
+
}
|
|
265
|
+
const envelope = (value, identity) => {
|
|
266
|
+
const item = object(value), record = object(item.record);
|
|
267
|
+
if (typeof item.canSubmit !== 'boolean' || !item.handle || typeof item.handle !== 'object'
|
|
268
|
+
|| record.version !== 1 || ['ownerUserId', 'apiOrigin', 'projectId'].some(key => record[key] !== identity[key])
|
|
269
|
+
|| typeof record.inputDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.inputDigest)
|
|
270
|
+
|| item.canSubmit && record.inputDigest !== identity.inputDigest
|
|
271
|
+
|| typeof record.jobId !== 'string' || record.jobId && !JOB.test(record.jobId)
|
|
272
|
+
|| !['submitted', 'unknown', 'completed', 'failed'].includes(record.state)
|
|
273
|
+
|| item.canSubmit && (record.jobId || record.state !== 'submitted')) fail('JOURNAL_UNAVAILABLE');
|
|
274
|
+
return { handle: item.handle, canSubmit: item.canSubmit, jobId: record.jobId || null };
|
|
275
|
+
};
|
|
276
|
+
const pause = () => checked(() => new Promise(resolve => {
|
|
277
|
+
let waiting;
|
|
278
|
+
const done = () => { clearTimeout(waiting); controller.signal.removeEventListener('abort', done); resolve(); };
|
|
279
|
+
waiting = setTimeout(done, pollMs); controller.signal.addEventListener('abort', done, { once: true });
|
|
280
|
+
if (controller.signal.aborted) done();
|
|
281
|
+
}));
|
|
282
|
+
try {
|
|
283
|
+
check(); publish('checking');
|
|
284
|
+
// A read-only pending-project probe has no input/body to persist. Its
|
|
285
|
+
// sentinel digest is never claimed, posted or used for legacy fallback.
|
|
286
|
+
const bytes = recoveryOnly || !body ? new Uint8Array(32)
|
|
287
|
+
: new Uint8Array(await checked(() => digest('SHA-256', encoder.encode(JSON.stringify(body)))));
|
|
288
|
+
if (bytes.byteLength !== 32) fail('INVALID');
|
|
289
|
+
const scopeBytes = new Uint8Array(await checked(() => digest('SHA-256', encoder.encode(JSON.stringify([operation, projectId, targetId])))));
|
|
290
|
+
if (scopeBytes.byteLength !== 32) fail('INVALID');
|
|
291
|
+
const identity = Object.freeze({ ownerUserId: captured.ownerUserId, apiOrigin: base, projectId: `durable-channel:v1:${Array.from(scopeBytes, byte => byte.toString(16).padStart(2, '0')).join('')}`,
|
|
292
|
+
inputDigest: Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') });
|
|
293
|
+
let found = await persist(() => storage.lookup(identity, { assertCurrent: check }));
|
|
294
|
+
if (!found && recoveryOnly) return null;
|
|
295
|
+
if (await checked(() => ensureSession()) === false) fail('SESSION_REQUIRED');
|
|
296
|
+
if (!found) {
|
|
297
|
+
const capability = await request(durableChannelCapabilityPath(operation));
|
|
298
|
+
if (capability.status !== 200) fail('UNAVAILABLE');
|
|
299
|
+
const mode = validateDurableChannelCapability(capability.body, operation);
|
|
300
|
+
// Re-observe after the capability await, including a deployment change.
|
|
301
|
+
found = await persist(() => storage.lookup(identity, { assertCurrent: check }));
|
|
302
|
+
if (!found && mode === 'legacy-sync') {
|
|
303
|
+
if (typeof legacy !== 'function') fail('UNAVAILABLE');
|
|
304
|
+
// This preserves the legacy callback; the journal does not provide
|
|
305
|
+
// atomic legacy/v2 exclusion during a mixed deployment mode switch.
|
|
306
|
+
publish('legacy-sync'); check(); legacyStarted = true;
|
|
307
|
+
return await checked(() => legacy(body || rawBody, { signal: controller.signal }));
|
|
308
|
+
}
|
|
309
|
+
if (!found && mode !== 'queue-v2') fail('UNAVAILABLE');
|
|
310
|
+
if (!found && !body) fail('INVALID');
|
|
311
|
+
if (!found) { if (typeof assertQueueReady !== 'function' || await checked(assertQueueReady) !== true) fail('INVALID'); claimStarted = true; found = await persist(() => storage.claim(identity, { assertCurrent: check })); }
|
|
312
|
+
}
|
|
313
|
+
const selected = envelope(found, identity); handle = selected.handle; jobId = selected.jobId;
|
|
314
|
+
recovering = !selected.canSubmit;
|
|
315
|
+
if (selected.canSubmit && !claimStarted) fail('JOURNAL_UNAVAILABLE');
|
|
316
|
+
if (selected.canSubmit) {
|
|
317
|
+
publish('submitting');
|
|
318
|
+
const reply = await request(`${durableChannelPath(operation)}/jobs`, 'POST'), value = object(reply.body);
|
|
319
|
+
const accepted = reply.status === 202 && value.ok === true && value.async === true;
|
|
320
|
+
const uncertain = reply.status === 503 && value.code === 'BACKGROUND_ENQUEUE_UNCONFIRMED';
|
|
321
|
+
const returnedId = accepted ? own(value.job, 'id') : uncertain ? value.jobId : undefined;
|
|
322
|
+
if (typeof returnedId !== 'string' || !JOB.test(returnedId)) fail('UNRESOLVED');
|
|
323
|
+
jobId = returnedId;
|
|
324
|
+
await persist(() => storage.rememberJob(handle, jobId));
|
|
325
|
+
}
|
|
326
|
+
if (!jobId) { publish('unknown'); fail('UNRESOLVED'); }
|
|
327
|
+
for (;;) {
|
|
328
|
+
publish('checking');
|
|
329
|
+
const reply = await request(`/api/tracker/channel-analysis/jobs/${jobId}`);
|
|
330
|
+
if (reply.status === 404) { publish('missing'); fail('MISSING'); }
|
|
331
|
+
const value = object(reply.body), job = object(value.job);
|
|
332
|
+
if (reply.status !== 200 || value.ok !== true || job.id !== jobId || !Object.values(DURABLE_CHANNEL_TYPES).includes(job.type)
|
|
333
|
+
|| !['queued', 'running', 'completed', 'failed', 'cancelled', 'unknown'].includes(job.status)) fail('RESPONSE_INVALID');
|
|
334
|
+
if (job.type !== DURABLE_CHANNEL_TYPES[operation]) fail('DIFFERENT_EDITOR');
|
|
335
|
+
if (job.status === 'completed') {
|
|
336
|
+
const result = validateCompletedDurableChannelJob(job, jobId);
|
|
337
|
+
if (result.targetId !== targetId || result.projectId !== projectId || result.operation !== operation) fail('DIFFERENT_EDITOR');
|
|
338
|
+
// A provider-completed job is not a durably applied browser result.
|
|
339
|
+
// Keep its known ID pending through UI/storage failures or page exits.
|
|
340
|
+
if (typeof applyResult !== 'function') fail('APPLY_UNCONFIRMED');
|
|
341
|
+
try { if (await checked(() => applyResult(Object.freeze({ ...result, recoveredPreviousRequest: recovering }))) !== true) fail('APPLY_UNCONFIRMED'); }
|
|
342
|
+
catch (error) { check(); if (failures.has(error)) throw error; fail('APPLY_UNCONFIRMED'); }
|
|
343
|
+
try { if (await checked(() => onAccountCost(result)) !== true) fail('APPLY_UNCONFIRMED'); }
|
|
344
|
+
catch (error) { check(); if (failures.has(error)) throw error; fail('APPLY_UNCONFIRMED'); }
|
|
345
|
+
await persist(() => storage.markResult(handle, { state: 'completed' })); publish('completed');
|
|
346
|
+
check(); return Object.freeze({ ...result, recoveredPreviousRequest: recovering });
|
|
347
|
+
}
|
|
348
|
+
publish(job.status);
|
|
349
|
+
if (!['queued', 'running'].includes(job.status)) {
|
|
350
|
+
await persist(() => storage.markResult(handle, { state: job.status === 'unknown' ? 'unknown' : 'failed' }));
|
|
351
|
+
fail('UNRESOLVED');
|
|
352
|
+
}
|
|
353
|
+
await pause();
|
|
354
|
+
}
|
|
355
|
+
} catch (caught) {
|
|
356
|
+
controller.abort(caught);
|
|
357
|
+
// Do not persist after a stopped authority. A submitted record without an
|
|
358
|
+
// ID already restores UNKNOWN, even if the POST never left this browser.
|
|
359
|
+
let changed = false; try { const fresh = authority(getAuthority); synchronous(assertCurrent);
|
|
360
|
+
changed = fresh.ownerUserId !== captured.ownerUserId || fresh.sessionRevision !== captured.sessionRevision; } catch { changed = true; }
|
|
361
|
+
const error = failure(changed ? 'AUTHORITY_CHANGED' : failures.get(caught) || 'RESPONSE_INVALID');
|
|
362
|
+
if (legacyStarted && !changed) {
|
|
363
|
+
const usage = legacyUsage(own(caught, 'providerUsage'));
|
|
364
|
+
if (usage) {
|
|
365
|
+
error.providerUsage = usage;
|
|
366
|
+
const model = own(caught, 'providerModel'); if (text(model, 160)) error.providerModel = model;
|
|
367
|
+
error.partialProviderUsage = own(caught, 'partialProviderUsage') === true;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (jobId) error.backgroundJobId = jobId;
|
|
371
|
+
error.submissionState = jobId ? status : handle || claimStarted || legacyStarted ? 'unknown' : 'not-submitted';
|
|
372
|
+
throw error;
|
|
373
|
+
} finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); active = false; }
|
|
374
|
+
} });
|
|
375
|
+
}
|
package/lib/durable-speech.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { DEFAULT_API_BASE } from "./config.mjs";
|
|
3
3
|
import { GENERATION_JOB_PATH } from "./generation-jobs.mjs";
|
|
4
|
+
import { captureTtsAlignment, buildSpeechSubtitleLines } from "./tts-alignment.mjs";
|
|
5
|
+
import { captureSpeechProductionProfile } from "./speech-production-profile.mjs";
|
|
4
6
|
|
|
5
7
|
const ID = /^[a-zA-Z0-9_-]{16,80}$/u, PROVIDERS = ["elevenlabs", "typecast", "supertone"];
|
|
6
8
|
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
|
+
const common = ["voice", "voiceId", "modelId", "fileName", "speed", "productionProfile"];
|
|
10
|
+
const perProvider = Object.freeze({ elevenlabs: ["languageCode", "similarityBoost", "speakerBoost", "stability", "style", "includeAlignment"],
|
|
9
11
|
typecast: ["language", "emotion", "pitch", "smartEmotion", "volume"], supertone: ["language", "lang", "style"] });
|
|
10
12
|
const invalid = () => new Error("Invalid durable speech request or result.");
|
|
11
13
|
const short = max => z.string().max(max);
|
|
@@ -14,6 +16,8 @@ const optionsSchema = z.object({ voice: short(320).optional(), voiceId: short(16
|
|
|
14
16
|
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
17
|
style: z.union([z.number().min(0).max(1), short(160)]).optional(), speakerBoost: z.boolean().optional(), smartEmotion: z.boolean().optional(),
|
|
16
18
|
pitch: z.number().int().min(-12).max(12).optional(), volume: z.number().int().min(0).max(200).optional(),
|
|
19
|
+
includeAlignment: z.boolean().optional().describe("ElevenLabs 전용 실제 발화 자막. 동일 합성 1회에서 오디오와 문자 타이밍을 함께 저장합니다."),
|
|
20
|
+
productionProfile: z.unknown().transform(captureSpeechProductionProfile).optional().describe("제작 핸들의 원래 productionProfile. 말투·길이·자막 디자인 의도를 유지합니다."),
|
|
17
21
|
}).strict();
|
|
18
22
|
export const durableSpeechToolSchema = Object.freeze({
|
|
19
23
|
requestId: z.string().regex(ID).describe("새 작업의 고유 ID. 응답이 끊겨도 이 ID를 유지하고 먼저 상태를 조회하세요."),
|
|
@@ -88,7 +92,17 @@ export function durableSpeechResult(job, { apiBase = DEFAULT_API_BASE } = {}) {
|
|
|
88
92
|
|| !(cost.accountCostRecorded === true && cost.accountCostSuppressedByReset !== true || cost.accountCostRecorded === false && cost.accountCostSuppressedByReset === true)) throw invalid();
|
|
89
93
|
const receipt = { accountCostEventId: cost.accountCostEventId, model: cost.model, provider: cost.provider, createdAt: cost.createdAt,
|
|
90
94
|
costUsd: cost.costUsd, accountCostRecorded: cost.accountCostRecorded, ...(cost.accountCostSuppressedByReset === true ? { accountCostSuppressedByReset: true } : {}) };
|
|
95
|
+
let captions = {};
|
|
96
|
+
if (["includeAlignment", "alignment", "normalizedAlignment", "alignedLines"].some(key => Object.hasOwn(payload, key))) {
|
|
97
|
+
if (payload.provider !== "elevenlabs" || payload.includeAlignment !== true) throw invalid();
|
|
98
|
+
const pair = captureTtsAlignment({ alignment: payload.alignment, normalizedAlignment: payload.normalizedAlignment });
|
|
99
|
+
const lines = buildSpeechSubtitleLines(pair);
|
|
100
|
+
if (!lines.length || !Array.isArray(payload.alignedLines) || JSON.stringify(lines) !== JSON.stringify(payload.alignedLines)) throw invalid();
|
|
101
|
+
captions = { includeAlignment: true, ...pair, alignedLines: lines, subtitleLines: lines, lineCount: lines.length,
|
|
102
|
+
durationSec: pair.alignment.character_end_times_seconds.at(-1), timingSource: "provider-character-alignment" };
|
|
103
|
+
}
|
|
91
104
|
return { generationJobId: job.id, status: "completed", kind: "tts", provider: payload.provider, filename: payload.filename, contentType: payload.contentType,
|
|
92
105
|
serverUrl: base.origin, assetPath: `${url.pathname}${url.search}`, assetUrl: url.href, ...receipt, hasAccountCostEvents: true, accountCostEvents: [receipt],
|
|
106
|
+
...captions,
|
|
93
107
|
guidance: "오디오가 서버의 계정 소유 자산으로 저장되었습니다. 이 링크를 사용하세요. 이 도구는 오디오를 다운로드하거나 PAT를 자산 요청에 전달하지 않습니다." };
|
|
94
108
|
}
|