aimakeall-mcp 0.14.3 → 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 +14 -0
- 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/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,12 @@ 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
|
+
|
|
8
14
|
## 0.14.3 리뷰 분석 업데이트
|
|
9
15
|
|
|
10
16
|
전편 분석의 서버 실행 방식 조회, 계정·서버별 체크포인트 고정, 알려진 작업의 읽기 전용
|
|
@@ -124,6 +130,14 @@ plan_shorts_video → (씬마다) generate_scene_image → get_generation_job(co
|
|
|
124
130
|
- 성공 응답의 `provider`, `primaryProvider`, `providerFallback`, `accountCostEvents`로 실제 합성 공급자와 비용을 확인합니다. 폴백 후 새 `productionPath`에 실제 보이스를 저장하며 이전 제작 핸들은 바꾸지 않습니다. 항상 최신 핸들을 다음 단계에 넘기세요.
|
|
125
131
|
- 제출 시간 초과·네트워크 단절·접수 후 결과 불명, 인증·입력·안전정책 오류에는 다른 유료 생성을 자동 실행하지 않습니다. 오류의 `fallbackStatus`, `fallbackReason`, `fallbackAvailable`, 접수된 비용을 확인하고 기존 작업부터 조회하세요. 실패했다고 비용이 없거나 환불되었다는 뜻은 아닙니다.
|
|
126
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`의 로컬 파일·제작 핸들을 자동 갱신하는 것은 아닙니다.
|
|
127
141
|
- 이미지·영상·LLM 폴백은 대응 서버가 지원하는 동일 기능/입력 경로에서만 동작합니다. SUNO, 음성 클로닝, 정밀 정렬 등 모든 독점 기능에 호환 대체가 있는 것은 아닙니다. 상태 조회만으로 미확인 유료 작업을 재생성하지 마세요.
|
|
128
142
|
|
|
129
143
|
## 장면 이미지 준비와 영상 표현 선택
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Exact browser capture of the server's durable TTS profile contract. This is
|
|
2
|
+
// creative intent, never receipt/state metadata or an arbitrary nested object.
|
|
3
|
+
export function captureSpeechProductionProfile(value) {
|
|
4
|
+
const fail = () => { throw Object.assign(new Error("음성 제작 프로필을 확인해 주세요."), { code: "SPEECH_INPUT_INVALID", paidReplayAllowed: false }); };
|
|
5
|
+
const fields = ["version", "categoryId", "tone", "deliveryStyle", "targetDurationSec", "imageSourceMode", "visualMotionMode", "autoComposeEnabled", "subtitleStylePresetId", "titleStylePresetId"];
|
|
6
|
+
if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
|
|
7
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
8
|
+
if (Reflect.ownKeys(descriptors).some(key => !fields.includes(key) || !Object.hasOwn(descriptors[key], "value"))) fail();
|
|
9
|
+
const copy = Object.fromEntries(Object.entries(descriptors).filter(([, item]) => item.value !== undefined).map(([key, item]) => [key, item.value]));
|
|
10
|
+
if (copy.version !== 1 || typeof copy.categoryId !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(copy.categoryId)
|
|
11
|
+
|| !["casual-social", "standard", "news-briefing"].includes(copy.deliveryStyle)) fail();
|
|
12
|
+
const maxSeconds = ["community-shorts", "viral-shorts"].includes(copy.categoryId) ? 180 : 3600;
|
|
13
|
+
if (typeof copy.targetDurationSec !== "number" || !Number.isFinite(copy.targetDurationSec) || copy.targetDurationSec < 0.01 || copy.targetDurationSec > maxSeconds) fail();
|
|
14
|
+
if (copy.tone !== undefined && (typeof copy.tone !== "string" || !copy.tone.isWellFormed() || copy.tone.length > 300 || /[\u0000-\u001f\u007f]/u.test(copy.tone))) fail();
|
|
15
|
+
if (copy.imageSourceMode !== undefined && !["ai", "web", "mixed"].includes(copy.imageSourceMode)) fail();
|
|
16
|
+
if (copy.visualMotionMode !== undefined && !["image-motion", "i2v"].includes(copy.visualMotionMode)) fail();
|
|
17
|
+
if (copy.autoComposeEnabled !== undefined && typeof copy.autoComposeEnabled !== "boolean") fail();
|
|
18
|
+
for (const key of ["subtitleStylePresetId", "titleStylePresetId"]) if (copy[key] !== undefined
|
|
19
|
+
&& (typeof copy[key] !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(copy[key]))) fail();
|
|
20
|
+
return Object.freeze(copy);
|
|
21
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Shared browser/server data contract. No provider, credentials, I/O or timing
|
|
2
|
+
// repair here: a malformed alignment is not permission to synthesize again.
|
|
3
|
+
// Two three-array alignments plus worst-case derived subtitle lines must fit
|
|
4
|
+
// the durable job's 65,536-node capture bound, including cost/receipt metadata.
|
|
5
|
+
export const TTS_ALIGNMENT_LIMITS = Object.freeze({ maxCharacters: 6000, maxDurationSeconds: 3600, maxJsonBytes: 1048576 });
|
|
6
|
+
const fail = () => { throw Object.assign(new Error("TTS_ALIGNMENT_INVALID"), { code: "TTS_ALIGNMENT_INVALID", statusCode: 502, paidReplayAllowed: false }); };
|
|
7
|
+
function record(value, names, required = names) {
|
|
8
|
+
if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
|
|
9
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
10
|
+
if (Reflect.ownKeys(fields).some(key => !names.includes(key) || !("value" in fields[key]))
|
|
11
|
+
|| required.some(key => !Object.hasOwn(fields, key))) fail();
|
|
12
|
+
return Object.fromEntries(Object.entries(fields).map(([key, item]) => [key, item.value]));
|
|
13
|
+
}
|
|
14
|
+
function array(value) {
|
|
15
|
+
if (!Array.isArray(value) || !value.length || value.length > TTS_ALIGNMENT_LIMITS.maxCharacters) fail();
|
|
16
|
+
const fields = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(fields);
|
|
17
|
+
if (keys.length !== value.length + 1 || keys.some(key => typeof key !== "string" || !("value" in fields[key])
|
|
18
|
+
|| key !== "length" && !/^(?:0|[1-9][0-9]*)$/u.test(key))) fail();
|
|
19
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
20
|
+
if (!Object.hasOwn(fields, index)) fail(); return fields[index].value;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function alignment(value) {
|
|
24
|
+
const source = record(value, ["characters", "character_start_times_seconds", "character_end_times_seconds"]);
|
|
25
|
+
const characters = array(source.characters), starts = array(source.character_start_times_seconds), ends = array(source.character_end_times_seconds);
|
|
26
|
+
if (characters.length !== starts.length || starts.length !== ends.length) fail();
|
|
27
|
+
for (let index = 0; index < characters.length; index++) {
|
|
28
|
+
const character = characters[index], start = starts[index], end = ends[index];
|
|
29
|
+
if (typeof character !== "string" || !character || !character.isWellFormed() || character.length > 16 || Array.from(character).length > 8
|
|
30
|
+
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(character)
|
|
31
|
+
|| typeof start !== "number" || typeof end !== "number" || !Number.isFinite(start) || !Number.isFinite(end)
|
|
32
|
+
|| start < 0 || end < start || end > TTS_ALIGNMENT_LIMITS.maxDurationSeconds
|
|
33
|
+
|| index > 0 && (start < starts[index - 1] || end < ends[index - 1])) fail();
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze({ characters: Object.freeze(characters), character_start_times_seconds: Object.freeze(starts), character_end_times_seconds: Object.freeze(ends) });
|
|
36
|
+
}
|
|
37
|
+
export function captureTtsAlignment(value, { requireAlignment = true } = {}) {
|
|
38
|
+
if (typeof requireAlignment !== "boolean") fail();
|
|
39
|
+
const source = record(value, ["alignment", "normalizedAlignment"], []);
|
|
40
|
+
const primary = source.alignment == null ? null : alignment(source.alignment);
|
|
41
|
+
if (!primary && requireAlignment) fail();
|
|
42
|
+
const result = Object.freeze({ alignment: primary, normalizedAlignment: source.normalizedAlignment == null ? null : alignment(source.normalizedAlignment) });
|
|
43
|
+
if (new TextEncoder().encode(JSON.stringify(result)).byteLength > TTS_ALIGNMENT_LIMITS.maxJsonBytes) fail();
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Preserve the existing ElevenLabs subtitle splitting contract. This function
|
|
48
|
+
// consumes a previously validated primary alignment; it never estimates time.
|
|
49
|
+
export function buildSpeechSubtitleLines({ alignment: source, minChars = 6 } = {}) {
|
|
50
|
+
const value = alignment(source);
|
|
51
|
+
if (!Number.isSafeInteger(minChars) || minChars < 1 || minChars > 1000) fail();
|
|
52
|
+
const chars = value.characters, starts = value.character_start_times_seconds, ends = value.character_end_times_seconds;
|
|
53
|
+
const breakers = new Set([".", "?", "!", "\n", "\r"]), softBreakers = new Set([",", ";", "·"]);
|
|
54
|
+
const lines = []; let buffer = "", lineStart = -1;
|
|
55
|
+
function pushLine(endIndex) {
|
|
56
|
+
const trimmed = buffer.trim(); if (!trimmed) return;
|
|
57
|
+
const startSec = starts[Math.max(0, lineStart)], endSec = ends[endIndex];
|
|
58
|
+
if (lines.length && trimmed.length < minChars) {
|
|
59
|
+
const previous = lines[lines.length - 1]; previous.text = `${previous.text} ${trimmed}`.trim(); previous.endSec = Math.max(previous.endSec, endSec);
|
|
60
|
+
} else lines.push({ text: trimmed, startSec, endSec });
|
|
61
|
+
buffer = ""; lineStart = -1;
|
|
62
|
+
}
|
|
63
|
+
for (let index = 0; index < chars.length; index++) {
|
|
64
|
+
const character = chars[index]; if (lineStart === -1 && character.trim()) lineStart = index; buffer += character;
|
|
65
|
+
if (breakers.has(character) || softBreakers.has(character) && buffer.replace(/[,\s;·]+$/, "").length >= minChars * 2) pushLine(index);
|
|
66
|
+
}
|
|
67
|
+
if (buffer.trim()) pushLine(chars.length - 1);
|
|
68
|
+
return Object.freeze(lines.map(Object.freeze));
|
|
69
|
+
}
|