@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.2
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 +5 -5
- package/package.json +10 -5
- package/src/domain/{revisions.ts → commit-message.ts} +7 -7
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +2 -2
- package/src/index.ts +7 -2
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +4 -4
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +36 -21
- package/src/node/document-assets.ts +30 -0
- package/src/node/document-audio.ts +473 -0
- package/src/node/document-authoring.ts +436 -0
- package/src/node/document-editor.ts +157 -0
- package/src/node/document-history.ts +190 -0
- package/src/node/document-images.ts +241 -0
- package/src/node/document-operations.ts +191 -0
- package/src/node/document-transcription-adoption.ts +96 -0
- package/src/node/document-transcription.ts +471 -0
- package/src/node/document-voice.ts +115 -0
- package/src/node/errors.ts +23 -0
- package/src/node/git.ts +15 -15
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +10 -5
- package/src/node/page-delete.ts +106 -0
- package/src/node/project-files.ts +163 -0
- package/src/node/projects.ts +192 -0
- package/src/node/review/agent-session-reviews.ts +374 -0
- package/src/node/review/git-guard.ts +9 -8
- package/src/node/review/git-review.ts +102 -90
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +36 -35
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +321 -68
- package/src/node/routes.ts +236 -146
- package/src/node/serve.ts +319 -118
- package/src/node/site.ts +34 -23
- package/src/node/source-editor.ts +332 -0
- package/src/node/speech-settings.ts +21 -0
- package/src/node/templates.ts +27 -0
- package/src/node/workbench-api.ts +176 -0
- package/src/node/writer.ts +389 -0
- package/templates/document/default/lecture.json +75 -0
- package/src/domain/api.ts +0 -102
- package/src/domain/index.ts +0 -6
- package/src/node/documents.ts +0 -185
- package/src/node/files.ts +0 -252
- package/src/node/review/review-record.ts +0 -73
- package/src/node/review/reviews.ts +0 -422
- package/src/node/service.ts +0 -237
- package/src/node/session.ts +0 -123
- package/src/node/source.ts +0 -148
- package/templates/document/default/lecture.md +0 -18
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, open } from 'node:fs/promises'
|
|
4
|
+
import { basename, join, relative } from 'node:path'
|
|
5
|
+
import { ASSET_TITLE_MAX_LENGTH, DOCUMENT_AUDIO_MAX_BYTES, DOCUMENT_AUDIO_REQUEST_MAX_BYTES, fnv1a64, hasRole, isRecord, jsonResponse, ProductionAudioRefusal, recordingSourceRange, recordingStepIssue, validateDocument } from '@lincyaw/kernel'
|
|
6
|
+
import type { Asset, CanonicalSource, DocumentRecordingAdoption, VerifiedDocumentRecordingAdoption, DocumentAudioAuthoring, DocumentAudioCapabilities, DocumentAudioImport, DocumentAudioInspectionAnswer, DocumentChangeAnswer, DocumentMediaClipEdit, DocumentMediaClipValidation, DocumentMediaClipValidationAnswer, DocumentWorkspaceAudioImport, Finding, Member, ProductionAudioAuthoring, RequestContext, Route, TeachingDocument, VerifiedDocumentAudioImport, VerifiedDocumentMediaClipChange, WorkspaceAudioInspectionAnswer } from '@lincyaw/kernel'
|
|
7
|
+
import { productionAssetFile, validateDocumentRecordingAdoption, validateDocumentAudioInspect, validateDocumentWorkspaceAudioImport, validateDocumentWorkspaceAudioInspect, validateDocumentMediaClipValidation, validateDocumentMediaClipEdit, validateDocumentProductionPlanQuery } from '@lincyaw/kernel/node'
|
|
8
|
+
import { readAuthoringDocument } from './document-editor.ts'
|
|
9
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
10
|
+
import type { Revisions } from './revisions.ts'
|
|
11
|
+
import type { SourceEditor, SourceTransactionHistory } from './source-editor.ts'
|
|
12
|
+
|
|
13
|
+
export interface DocumentAudioServices { audio?: ProductionAudioAuthoring, findings?: Finding[] }
|
|
14
|
+
export interface DocumentAudioAuthoringOptions extends DocumentAudioServices {
|
|
15
|
+
root: string
|
|
16
|
+
editor: SourceEditor
|
|
17
|
+
revisions?: Revisions
|
|
18
|
+
reviewRequired?: boolean
|
|
19
|
+
services?(document: string, source: CanonicalSource): Promise<DocumentAudioServices>
|
|
20
|
+
}
|
|
21
|
+
interface Receipt { signature: string, source?: CanonicalSource, version: string, files: DocumentChangeAnswer['files'], commit?: DocumentChangeAnswer['commit'], assetId?: string, inspection?: VerifiedDocumentAudioImport['inspection'], clip?: Pick<VerifiedDocumentMediaClipChange['verification'], 'production' | 'binding'>, recording?: Pick<VerifiedDocumentRecordingAdoption['verification'], 'stepId' | 'sha256' | 'durationMs' | 'sourceRange'> }
|
|
22
|
+
interface Operation { controller: AbortController, finished: Promise<unknown> }
|
|
23
|
+
const MAX_RECEIPTS = 64, MAX_CONCURRENT = 4
|
|
24
|
+
const header = (value: unknown): value is DocumentAudioImport => isRecord(value) && typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 200 && typeof value.baseDocumentVersion === 'string' && /^[\da-f]{20}$/.test(value.baseDocumentVersion)
|
|
25
|
+
const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex')
|
|
26
|
+
const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
|
|
27
|
+
const scopeOf = (document: string) => ({ document, home: false })
|
|
28
|
+
const binding = (current: ReturnType<typeof readAuthoringDocument>): CanonicalSource => ({ documentId: current.content.id, version: current.source.version, fingerprint: fnv1a64(current.source.content) })
|
|
29
|
+
const titleOf = (name: string) => [...(name.replaceAll('\\', '/').split('/').at(-1) ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()].slice(0, ASSET_TITLE_MAX_LENGTH).join('')
|
|
30
|
+
const stamp = (stat: Awaited<ReturnType<Awaited<ReturnType<typeof open>>['stat']>>) => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':')
|
|
31
|
+
|
|
32
|
+
/** Owns the canonical transaction and receipts; plugin services only inspect actual media. */
|
|
33
|
+
export function createDocumentAudioAuthoring({ root, editor, revisions, audio, services, reviewRequired = false }: DocumentAudioAuthoringOptions): { service: DocumentAudioAuthoring, routes: Record<string, Route>, close(): Promise<void> } {
|
|
34
|
+
const receipts = new Map<string, Receipt>(), imports = new Map<string, { signature: string, source: CanonicalSource, promise: Promise<VerifiedDocumentAudioImport> }>(), operations = new Set<Operation>()
|
|
35
|
+
const clipChanges = new Map<string, { signature: string, promise: Promise<VerifiedDocumentMediaClipChange> }>()
|
|
36
|
+
const recordingChanges = new Map<string, { signature: string, promise: Promise<VerifiedDocumentRecordingAdoption> }>()
|
|
37
|
+
let closed = false
|
|
38
|
+
const authorize = (member: Member) => { if (!hasRole(member, 'editor')) throw new RequestError(403, 'audio-authoring-permission-denied') }
|
|
39
|
+
const available = () => { if (closed) throw new RequestError(503, 'audio-authoring-closed') }
|
|
40
|
+
const check = (signal?: AbortSignal) => { available(); signal?.throwIfAborted() }
|
|
41
|
+
const work = <T>(member: Member, signal: AbortSignal | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T> => {
|
|
42
|
+
authorize(member); check(signal)
|
|
43
|
+
if (operations.size >= MAX_CONCURRENT) throw new RequestError(503, 'audio-authoring-busy')
|
|
44
|
+
const controller = new AbortController(), combined = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal
|
|
45
|
+
const operation: Operation = { controller, finished: Promise.resolve() }
|
|
46
|
+
operations.add(operation)
|
|
47
|
+
const finished = Promise.resolve().then(() => { check(combined); return run(combined) }).finally(() => operations.delete(operation))
|
|
48
|
+
operation.finished = finished
|
|
49
|
+
return finished
|
|
50
|
+
}
|
|
51
|
+
const ctxOf = (document: string, member: Member): RequestContext => ({ document, member, rest: '' })
|
|
52
|
+
const opened = (ctx: RequestContext, version?: string, source?: CanonicalSource) => {
|
|
53
|
+
const current = readAuthoringDocument(editor, ctx)
|
|
54
|
+
if (version !== undefined && current.source.version !== version || source && !sameSource(binding(current), source)) throw new RequestError(409, 'document-version-changed', { currentSource: binding(current) })
|
|
55
|
+
return current
|
|
56
|
+
}
|
|
57
|
+
const resolved = async (document: string, source: CanonicalSource, signal: AbortSignal) => {
|
|
58
|
+
check(signal)
|
|
59
|
+
const result = services ? await services(document, source) : { audio }
|
|
60
|
+
check(signal)
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
const workspacePath = (document: string, path: string, member: Member) => {
|
|
64
|
+
editor.assertEditable(scopeOf(document), path, member)
|
|
65
|
+
if (path.split('/').some(part => part.startsWith('.'))) throw new RequestError(403, 'audio-file-path')
|
|
66
|
+
}
|
|
67
|
+
const reader = async (ctx: RequestContext, path: string, signal?: AbortSignal): Promise<Uint8Array> => {
|
|
68
|
+
signal?.throwIfAborted()
|
|
69
|
+
const file = editor.readableFile(scopeOf(ctx.document), path, ctx.member)
|
|
70
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
|
|
71
|
+
if (error.code === 'ELOOP') throw new RequestError(403, 'audio-file-path')
|
|
72
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') throw new RequestError(404, 'audio-file-missing')
|
|
73
|
+
throw error
|
|
74
|
+
})
|
|
75
|
+
try {
|
|
76
|
+
const before = await handle.stat(), named = await lstat(editor.readableFile(scopeOf(ctx.document), path, ctx.member))
|
|
77
|
+
if (!before.isFile() || before.dev !== named.dev || before.ino !== named.ino) throw new RequestError(403, 'audio-file-path')
|
|
78
|
+
if (!before.size || before.size > DOCUMENT_AUDIO_MAX_BYTES) throw new RequestError(413, 'production.asset-size')
|
|
79
|
+
const bytes = new Uint8Array(before.size + 1); let length = 0
|
|
80
|
+
while (length < bytes.length) {
|
|
81
|
+
signal?.throwIfAborted()
|
|
82
|
+
const read = await handle.read(bytes, length, bytes.length - length, length)
|
|
83
|
+
if (!read.bytesRead) break
|
|
84
|
+
length += read.bytesRead
|
|
85
|
+
}
|
|
86
|
+
const after = await handle.stat(), current = await lstat(editor.readableFile(scopeOf(ctx.document), path, ctx.member))
|
|
87
|
+
if (length !== before.size || stamp(before) !== stamp(after) || after.dev !== current.dev || after.ino !== current.ino) throw new RequestError(409, 'audio-file-changed')
|
|
88
|
+
signal?.throwIfAborted()
|
|
89
|
+
return bytes.slice(0, length)
|
|
90
|
+
} finally { await handle.close() }
|
|
91
|
+
}
|
|
92
|
+
const pathOf = (ctx: RequestContext, asset: Asset) => {
|
|
93
|
+
const file = productionAssetFile(join(root, ctx.document), asset.location)
|
|
94
|
+
if (!file) throw new RequestError(422, 'production.asset-path')
|
|
95
|
+
return relative(root, file).split('\\').join('/')
|
|
96
|
+
}
|
|
97
|
+
const inspect = async (ctx: RequestContext, asset: Asset, source: CanonicalSource, signal: AbortSignal) => {
|
|
98
|
+
if (asset.kind !== 'audio') throw new RequestError(422, 'production.asset-kind')
|
|
99
|
+
const [bytes, found] = await Promise.all([reader(ctx, pathOf(ctx, asset), signal), resolved(ctx.document, source, signal)])
|
|
100
|
+
if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
|
|
101
|
+
if (asset.fingerprint && asset.fingerprint.replace(/^sha256:/, '').toLowerCase() !== hash(bytes)) throw new RequestError(409, 'production.asset-changed')
|
|
102
|
+
const { inspection } = await found.audio.inspectBytes(asset.id, bytes, signal)
|
|
103
|
+
check(signal); opened(ctx, source.version, source)
|
|
104
|
+
return inspection
|
|
105
|
+
}
|
|
106
|
+
const history = (ctx: RequestContext, content: TeachingDocument, origin: 'editor' | 'agent', validate?: () => Promise<void>): SourceTransactionHistory => ({
|
|
107
|
+
run: work => { const guarded = async () => { await validate?.(); return work() }; return revisions ? revisions.transaction(guarded) : guarded() },
|
|
108
|
+
commit: async paths => {
|
|
109
|
+
if (!revisions) return undefined
|
|
110
|
+
const ignored = (await Promise.all(paths.map(async path => await revisions.ignores(path) ? path : undefined))).filter((path): path is string => Boolean(path))
|
|
111
|
+
if (ignored.length) throw new RequestError(409, 'document-files-ignored', { ignored })
|
|
112
|
+
return revisions.commitWhileLocked({ paths, summary: content.metadata.lang?.startsWith('en') ? 'Edit audio media' : '编辑音频素材', body: '', origin: { kind: origin, member: ctx.member } })
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
const remember = (key: string, receipt: Receipt) => { receipts.set(key, receipt); while (receipts.size > MAX_RECEIPTS) receipts.delete(receipts.keys().next().value!) }
|
|
116
|
+
const readback = async (ctx: RequestContext, id: string, receipt: Receipt): Promise<VerifiedDocumentAudioImport> => {
|
|
117
|
+
try {
|
|
118
|
+
const current = opened(ctx, receipt.version), assetId = receipt.assetId!, asset = current.content.assets[assetId], inspection = receipt.inspection!
|
|
119
|
+
if (!asset || asset.kind !== 'audio' || asset.fingerprint !== `sha256:${inspection.sha256}`) throw new RequestError(409, 'audio-readback-changed')
|
|
120
|
+
const path = pathOf(ctx, asset), bytes = await reader(ctx, path)
|
|
121
|
+
if (hash(bytes) !== inspection.sha256 || bytes.length !== inspection.byteLength || receipt.files.some(file => file.path === path ? file.version !== inspection.sha256.slice(0, 20) : editor.read(scopeOf(ctx.document), file.path, ctx.member).version !== file.version)) throw new RequestError(409, 'audio-readback-changed')
|
|
122
|
+
const verified = opened(ctx, receipt.version)
|
|
123
|
+
return { document: ctx.document, version: verified.source.version, fingerprint: fnv1a64(verified.source.content), content: verified.content, issues: verified.issues, changeId: id, assetId, inspection, files: receipt.files, ...receipt.commit ? { commit: receipt.commit } : {}, verification: { saved: true, source: binding(verified), asset: { assetId, path, sha256: inspection.sha256, byteLength: bytes.length }, compiled: 'not-requested', runtime: 'not-requested' } }
|
|
124
|
+
} catch (error) {
|
|
125
|
+
let currentSource: CanonicalSource | undefined
|
|
126
|
+
try { currentSource = binding(opened(ctx)) } catch { /* The current document may be unavailable after a committed save. */ }
|
|
127
|
+
throw new RequestError(409, 'audio-readback-changed', { committed: true, changeId: id, assetId: receipt.assetId, version: receipt.version, ...currentSource ? { currentSource } : {}, reason: error instanceof Error ? error.message : 'readback-failed' })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const importKey = (ctx: RequestContext, id: string) => JSON.stringify(['audio', ctx.document, ctx.member.id, id])
|
|
131
|
+
const importSignature = (change: DocumentAudioImport, source: CanonicalSource, title: string, sha256: string, byteLength: number) => hash(JSON.stringify([change.id, source.documentId, change.baseDocumentVersion, source.fingerprint, title, sha256, byteLength]))
|
|
132
|
+
/** A known intent replays (receipt still current) or joins its flight; a different payload under the same ID is refused. */
|
|
133
|
+
const known = (ctx: RequestContext, signature: string, previous: Receipt | undefined, pending: { signature: string } | undefined) => {
|
|
134
|
+
if (!previous && !pending) return false
|
|
135
|
+
if ((previous ?? pending)!.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
|
|
136
|
+
if (previous) opened(ctx, previous.version)
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
const once = (ctx: RequestContext, change: DocumentAudioImport, source: CanonicalSource, title: string, sha256: string, byteLength: number, origin: 'editor' | 'agent', run: () => Promise<VerifiedDocumentAudioImport>) => {
|
|
140
|
+
authorize(ctx.member); available()
|
|
141
|
+
if (origin === 'agent' && reviewRequired) throw new RequestError(409, 'authoring-review-required')
|
|
142
|
+
const key = importKey(ctx, change.id), signature = importSignature(change, source, title, sha256, byteLength)
|
|
143
|
+
const previous = receipts.get(key)
|
|
144
|
+
if (previous) {
|
|
145
|
+
if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
|
|
146
|
+
opened(ctx, previous.version)
|
|
147
|
+
return readback(ctx, change.id, previous)
|
|
148
|
+
}
|
|
149
|
+
const pending = imports.get(key)
|
|
150
|
+
if (pending) { if (pending.signature !== signature) throw new RequestError(409, 'document-change-id-reused'); return pending.promise }
|
|
151
|
+
const promise = Promise.resolve().then(run).finally(() => { if (imports.get(key)?.promise === promise) imports.delete(key) })
|
|
152
|
+
imports.set(key, { signature, source, promise })
|
|
153
|
+
return promise
|
|
154
|
+
}
|
|
155
|
+
const importBytes = async (ctx: RequestContext, change: DocumentAudioImport, title: string, bytes: Uint8Array, source: CanonicalSource, found: DocumentAudioServices, origin: 'editor' | 'agent', signal: AbortSignal) => {
|
|
156
|
+
check(signal)
|
|
157
|
+
if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
|
|
158
|
+
const current = opened(ctx, change.baseDocumentVersion, source), assetId = `audio_${randomUUID().replaceAll('-', '')}`, probed = await found.audio.inspectBytes(assetId, bytes, signal)
|
|
159
|
+
if (probed.inspection.sha256 !== hash(bytes) || probed.inspection.byteLength !== bytes.length) throw new RequestError(409, 'audio-inspection-changed')
|
|
160
|
+
const location = `assets/audio/${assetId}.${probed.extension}`, path = ctx.document ? `${ctx.document}/${location}` : location, candidate = structuredClone(current.content)
|
|
161
|
+
candidate.assets[assetId] = { id: assetId, kind: 'audio', location, ...title ? { title } : {}, fingerprint: `sha256:${probed.inspection.sha256}`, mediaType: probed.inspection.mediaType }
|
|
162
|
+
const content = `${JSON.stringify(candidate, null, 2)}\n`
|
|
163
|
+
check(signal); opened(ctx, change.baseDocumentVersion, source)
|
|
164
|
+
const saved = await editor.transactionWithBinary(scopeOf(ctx.document), { path: current.source.path, content, base: current.source.version }, { path, content: bytes }, ctx.member, history(ctx, candidate, origin, async () => { check(signal) }))
|
|
165
|
+
const version = saved.versions.find(file => file.path === current.source.path)!.version
|
|
166
|
+
const receipt: Receipt = { signature: importSignature(change, source, title, hash(bytes), bytes.length), source, version, files: saved.versions, assetId, inspection: probed.inspection, ...saved.revision ? { commit: saved.revision } : {} }
|
|
167
|
+
remember(importKey(ctx, change.id), receipt)
|
|
168
|
+
// A committed write is verified even when its request disconnected or shutdown began meanwhile.
|
|
169
|
+
return readback(ctx, change.id, receipt)
|
|
170
|
+
}
|
|
171
|
+
const prepareClip = (ctx: RequestContext, request: DocumentMediaClipValidation) => {
|
|
172
|
+
const current = opened(ctx, request.source.version, request.source), candidate = structuredClone(current.content)
|
|
173
|
+
const clipId = 'clip' in request ? request.clip.id : request.removeClipId, before = candidate.production.mediaClips.find(clip => clip.id === clipId)
|
|
174
|
+
if ('removeClipId' in request) {
|
|
175
|
+
if (!before) throw new RequestError(404, 'document-media-clip-missing')
|
|
176
|
+
candidate.production.mediaClips = candidate.production.mediaClips.filter(clip => clip.id !== clipId)
|
|
177
|
+
} else {
|
|
178
|
+
if (!sameSource(request.binding.source, request.source)) throw new RequestError(409, 'production-binding-changed')
|
|
179
|
+
const index = candidate.production.mediaClips.findIndex(clip => clip.id === clipId)
|
|
180
|
+
if (index < 0) candidate.production.mediaClips.push(structuredClone(request.clip))
|
|
181
|
+
else candidate.production.mediaClips[index] = structuredClone(request.clip)
|
|
182
|
+
}
|
|
183
|
+
const checked = validateDocument(candidate)
|
|
184
|
+
if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
|
|
185
|
+
const after = checked.document.production.mediaClips.find(clip => clip.id === clipId)
|
|
186
|
+
const answer: DocumentMediaClipValidationAnswer = {
|
|
187
|
+
document: ctx.document, source: request.source, operation: 'removeClipId' in request ? 'remove' : before ? 'update' : 'add', clipId,
|
|
188
|
+
...before ? { before } : {}, ...after ? { after } : {},
|
|
189
|
+
impact: { stepIds: [...new Set([before?.attachment.stepId, after?.attachment.stepId].filter((id): id is string => id !== undefined))], assetIds: [...new Set([before?.assetId, after?.assetId].filter((id): id is string => id !== undefined))] },
|
|
190
|
+
issues: checked.issues, checked: { canonical: true, production: 'clip' in request ? 'validated' : 'not-required', ...'clip' in request ? { binding: request.binding } : {} },
|
|
191
|
+
}
|
|
192
|
+
const validate = async (signal: AbortSignal) => {
|
|
193
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
194
|
+
if ('clip' in request) {
|
|
195
|
+
for (const id of new Set(checked.document!.production.mediaClips.map(clip => clip.assetId))) {
|
|
196
|
+
const asset = checked.document!.assets[id], file = asset && productionAssetFile(join(root, ctx.document), asset.location)
|
|
197
|
+
if (!file) throw new RequestError(422, 'production.asset-path')
|
|
198
|
+
editor.readableFile(scopeOf(ctx.document), relative(root, file).split('\\').join('/'), ctx.member)
|
|
199
|
+
}
|
|
200
|
+
const found = await resolved(ctx.document, request.source, signal)
|
|
201
|
+
if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
|
|
202
|
+
await found.audio.validateClips(request.binding, checked.document!.production.mediaClips, checked.document!.assets, signal)
|
|
203
|
+
}
|
|
204
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
205
|
+
}
|
|
206
|
+
return { current, candidate: checked.document, content: `${JSON.stringify(checked.document, null, 2)}\n`, answer, validate }
|
|
207
|
+
}
|
|
208
|
+
const clipReadback = (ctx: RequestContext, id: string, receipt: Receipt): VerifiedDocumentMediaClipChange => {
|
|
209
|
+
try {
|
|
210
|
+
const current = opened(ctx, receipt.version)
|
|
211
|
+
if (receipt.files.some(file => editor.read(scopeOf(ctx.document), file.path, ctx.member).version !== file.version)) throw new RequestError(409, 'media-clip-readback-changed')
|
|
212
|
+
return { document: ctx.document, version: current.source.version, fingerprint: fnv1a64(current.source.content), content: current.content, issues: current.issues, changeId: id, files: receipt.files, ...receipt.commit ? { commit: receipt.commit } : {}, verification: { saved: true, source: binding(current), ...receipt.clip!, compiled: 'not-requested', runtime: 'not-requested' } }
|
|
213
|
+
} catch (error) {
|
|
214
|
+
let currentSource: CanonicalSource | undefined
|
|
215
|
+
try { currentSource = binding(opened(ctx)) } catch { /* Report the committed receipt even when its document is unavailable. */ }
|
|
216
|
+
throw new RequestError(409, 'media-clip-readback-changed', { committed: true, changeId: id, version: receipt.version, ...currentSource ? { currentSource } : {}, reason: error instanceof Error ? error.message : 'readback-failed' })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const clipIntent = (ctx: RequestContext, request: DocumentMediaClipEdit, origin: 'editor' | 'agent') => {
|
|
220
|
+
if (!validateDocumentMediaClipEdit(request)) throw new RequestError(400, 'document-media-clip-invalid')
|
|
221
|
+
if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
|
|
222
|
+
if (origin === 'agent' && reviewRequired) throw new RequestError(409, 'authoring-review-required')
|
|
223
|
+
const key = JSON.stringify(['clip', ctx.document, ctx.member.id, request.id]), source = request.source
|
|
224
|
+
const signature = hash(JSON.stringify([request.id, source.documentId, source.version, source.fingerprint, ...'clip' in request ? [request.binding.irRevision, request.binding.planId, request.binding.source.documentId, request.binding.source.version, request.binding.source.fingerprint, request.clip.id, request.clip.assetId, request.clip.sourceRange.startMs, request.clip.sourceRange.endMs, request.clip.attachment.stepId, request.clip.attachment.offsetMs, request.clip.volume, request.clip.muted ?? false] : [request.removeClipId]]))
|
|
225
|
+
return { key, source, signature }
|
|
226
|
+
}
|
|
227
|
+
const changeClip = (ctx: RequestContext, request: DocumentMediaClipEdit, origin: 'editor' | 'agent', signal: AbortSignal): Promise<VerifiedDocumentMediaClipChange> => {
|
|
228
|
+
const { key, source, signature } = clipIntent(ctx, request, origin)
|
|
229
|
+
const previous = receipts.get(key)
|
|
230
|
+
if (previous) {
|
|
231
|
+
if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
|
|
232
|
+
opened(ctx, previous.version)
|
|
233
|
+
return Promise.resolve(clipReadback(ctx, request.id, previous))
|
|
234
|
+
}
|
|
235
|
+
const pending = clipChanges.get(key)
|
|
236
|
+
if (pending) { if (pending.signature !== signature) throw new RequestError(409, 'document-change-id-reused'); return pending.promise }
|
|
237
|
+
const promise = Promise.resolve().then(async () => {
|
|
238
|
+
check(signal)
|
|
239
|
+
const prepared = prepareClip(ctx, request)
|
|
240
|
+
const saved = await editor.transaction(scopeOf(ctx.document), [{ path: prepared.current.source.path, content: prepared.content, base: source.version }], ctx.member, history(ctx, prepared.candidate, origin, () => prepared.validate(signal)))
|
|
241
|
+
const receipt: Receipt = { signature, source, version: saved.versions[0]!.version, files: saved.versions, ...saved.revision ? { commit: saved.revision } : {}, clip: { production: 'clip' in request ? 'validated-before-save' : 'not-required', ...'clip' in request ? { binding: request.binding } : {} } }
|
|
242
|
+
remember(key, receipt)
|
|
243
|
+
return clipReadback(ctx, request.id, receipt)
|
|
244
|
+
}).finally(() => { if (clipChanges.get(key)?.promise === promise) clipChanges.delete(key) })
|
|
245
|
+
clipChanges.set(key, { signature, promise })
|
|
246
|
+
return promise
|
|
247
|
+
}
|
|
248
|
+
const recordingReadback = async (ctx: RequestContext, id: string, receipt: Receipt): Promise<VerifiedDocumentRecordingAdoption> => {
|
|
249
|
+
try {
|
|
250
|
+
const current = opened(ctx, receipt.version), recording = receipt.recording!, assetId = receipt.assetId!, asset = current.content.assets[assetId]
|
|
251
|
+
const source = current.content.production.stepOverrides[recording.stepId]?.voiceSource
|
|
252
|
+
if (source?.type !== 'recording' || source.assetId !== assetId || JSON.stringify(source.sourceRange) !== JSON.stringify(recording.sourceRange) || !asset || asset.fingerprint !== `sha256:${recording.sha256}`) throw new RequestError(409, 'recording-readback-changed')
|
|
253
|
+
const bytes = await reader(ctx, pathOf(ctx, asset))
|
|
254
|
+
if (hash(bytes) !== recording.sha256 || receipt.files.some(file => editor.read(scopeOf(ctx.document), file.path, ctx.member).version !== file.version)) throw new RequestError(409, 'recording-readback-changed')
|
|
255
|
+
const verified = opened(ctx, receipt.version)
|
|
256
|
+
const range = recordingSourceRange(source, recording.durationMs)
|
|
257
|
+
if (!range) throw new RequestError(409, 'recording-readback-changed')
|
|
258
|
+
return { document: ctx.document, version: verified.source.version, fingerprint: fnv1a64(verified.source.content), content: verified.content, issues: verified.issues, changeId: id, files: receipt.files, ...receipt.commit ? { commit: receipt.commit } : {}, verification: { saved: true, source: binding(verified), assetId, ...recording, sourceRange: range, usedDurationMs: range.endMs - range.startMs, compiled: 'not-requested', runtime: 'not-requested' } }
|
|
259
|
+
} catch (error) {
|
|
260
|
+
let currentSource: CanonicalSource | undefined
|
|
261
|
+
try { currentSource = binding(opened(ctx)) } catch { /* Preserve a committed receipt if the saved source is no longer readable. */ }
|
|
262
|
+
throw new RequestError(409, 'recording-readback-changed', { committed: true, changeId: id, version: receipt.version, ...currentSource ? { currentSource } : {}, reason: error instanceof Error ? error.message : 'readback-failed' })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const recordingIntent = (ctx: RequestContext, request: DocumentRecordingAdoption, origin: 'editor' | 'agent') => {
|
|
266
|
+
if (!validateDocumentRecordingAdoption(request)) throw new RequestError(400, 'document-recording-invalid')
|
|
267
|
+
if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
|
|
268
|
+
if (origin === 'agent' && reviewRequired) throw new RequestError(409, 'authoring-review-required')
|
|
269
|
+
return { key: JSON.stringify(['recording', ctx.document, ctx.member.id, request.id]), signature: hash(JSON.stringify([request.id, request.source.documentId, request.source.version, request.source.fingerprint, request.stepId, request.assetId, request.sha256, request.sourceRange])) }
|
|
270
|
+
}
|
|
271
|
+
const prepareRecording = async (ctx: RequestContext, request: DocumentRecordingAdoption, signal: AbortSignal) => {
|
|
272
|
+
check(signal)
|
|
273
|
+
const current = opened(ctx, request.source.version, request.source), step = current.content.steps[request.stepId], asset = current.content.assets[request.assetId]
|
|
274
|
+
if (!step) throw new RequestError(404, 'document-step-missing')
|
|
275
|
+
if (!asset) throw new RequestError(404, 'production.asset-missing')
|
|
276
|
+
const original = current.content.production.stepOverrides[step.id]?.voiceSource ?? current.content.production.sectionOverrides?.[step.sectionId]?.voiceSource ?? current.content.production.defaults.voiceSource
|
|
277
|
+
if (request.sourceRange && (original?.type !== 'recording' || original.assetId !== asset.id)) throw new RequestError(409, 'recording-source-changed')
|
|
278
|
+
const issue = recordingStepIssue(current.content, request.stepId)
|
|
279
|
+
if (issue) throw new RequestError(422, issue)
|
|
280
|
+
const inspection = await inspect(ctx, asset, request.source, signal)
|
|
281
|
+
if (inspection.sha256 !== request.sha256) throw new RequestError(409, 'production.asset-changed')
|
|
282
|
+
const voice = { type: 'recording' as const, assetId: asset.id, ...request.sourceRange ? { sourceRange: { ...request.sourceRange } } : {} }
|
|
283
|
+
if (!recordingSourceRange(voice, inspection.durationMs)) throw new RequestError(422, 'production.media-range')
|
|
284
|
+
const candidate = structuredClone(current.content)
|
|
285
|
+
candidate.assets[asset.id]!.fingerprint = `sha256:${inspection.sha256}`
|
|
286
|
+
candidate.production.stepOverrides[step.id] = { ...candidate.production.stepOverrides[step.id], voiceSource: voice }
|
|
287
|
+
const checked = validateDocument(candidate)
|
|
288
|
+
if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
|
|
289
|
+
return { current, step, asset, inspection, voice, document: checked.document, content: `${JSON.stringify(checked.document, null, 2)}\n` }
|
|
290
|
+
}
|
|
291
|
+
const adoptRecording = (ctx: RequestContext, request: DocumentRecordingAdoption, origin: 'editor' | 'agent', signal: AbortSignal): Promise<VerifiedDocumentRecordingAdoption> => {
|
|
292
|
+
const { key, signature } = recordingIntent(ctx, request, origin)
|
|
293
|
+
const previous = receipts.get(key)
|
|
294
|
+
if (previous) {
|
|
295
|
+
if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
|
|
296
|
+
opened(ctx, previous.version)
|
|
297
|
+
return recordingReadback(ctx, request.id, previous)
|
|
298
|
+
}
|
|
299
|
+
const pending = recordingChanges.get(key)
|
|
300
|
+
if (pending) { if (pending.signature !== signature) throw new RequestError(409, 'document-change-id-reused'); return pending.promise }
|
|
301
|
+
const promise = Promise.resolve().then(async () => {
|
|
302
|
+
const { current, step, asset, inspection, voice, document, content } = await prepareRecording(ctx, request, signal)
|
|
303
|
+
const validate = async () => {
|
|
304
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
305
|
+
const actual = await inspect(ctx, asset, request.source, signal)
|
|
306
|
+
if (actual.sha256 !== request.sha256 || actual.durationMs !== inspection.durationMs) throw new RequestError(409, 'production.asset-changed')
|
|
307
|
+
if (!recordingSourceRange(voice, actual.durationMs)) throw new RequestError(422, 'production.media-range')
|
|
308
|
+
check(signal)
|
|
309
|
+
}
|
|
310
|
+
const saved = await editor.transaction(scopeOf(ctx.document), [{ path: current.source.path, content, base: request.source.version }], ctx.member, history(ctx, document, origin, validate))
|
|
311
|
+
const receipt: Receipt = { signature, source: request.source, version: saved.versions[0]!.version, files: saved.versions, assetId: asset.id, recording: { stepId: step.id, sha256: inspection.sha256, durationMs: inspection.durationMs, ...request.sourceRange ? { sourceRange: { ...request.sourceRange } } : {} }, ...saved.revision ? { commit: saved.revision } : {} }
|
|
312
|
+
remember(key, receipt)
|
|
313
|
+
return recordingReadback(ctx, request.id, receipt)
|
|
314
|
+
}).finally(() => { if (recordingChanges.get(key)?.promise === promise) recordingChanges.delete(key) })
|
|
315
|
+
recordingChanges.set(key, { signature, promise })
|
|
316
|
+
return promise
|
|
317
|
+
}
|
|
318
|
+
const workspaceImport = (document: string, request: DocumentWorkspaceAudioImport, member: Member) => {
|
|
319
|
+
authorize(member); available()
|
|
320
|
+
if (!validateDocumentWorkspaceAudioImport(request)) throw new RequestError(400, 'document-audio-import-invalid')
|
|
321
|
+
if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
|
|
322
|
+
workspacePath(document, request.file.path, member)
|
|
323
|
+
return { ctx: ctxOf(document, member), title: titleOf(basename(request.file.path)), change: { id: request.id, baseDocumentVersion: request.source.version } }
|
|
324
|
+
}
|
|
325
|
+
const service: DocumentAudioAuthoring = {
|
|
326
|
+
capabilities: (document, member) => work(member, undefined, async signal => {
|
|
327
|
+
const ctx = ctxOf(document, member), current = opened(ctx), source = binding(current), found = await resolved(document, source, signal)
|
|
328
|
+
opened(ctx, source.version, source)
|
|
329
|
+
const available = Boolean(found.audio), unknown = !available && Boolean(found.findings?.some(finding => finding.severity === 'error'))
|
|
330
|
+
return { source, available, status: available ? 'available' : unknown ? 'unknown' : 'unavailable', ...unknown ? { reason: 'services-unavailable' as const } : !available ? { reason: 'audio-unavailable' as const } : {}, verification: 'configuration-only', ...found.findings?.length ? { findings: found.findings } : {}, actions: { inspect: available, inspectWorkspace: available, importWorkspace: available && !reviewRequired, readProductionPlan: available, validateClipChange: true, changeClip: !reviewRequired, adoptRecording: available && !reviewRequired }, ...reviewRequired ? { importUnavailableReason: 'review-required' as const, clipChangeUnavailableReason: 'review-required' as const } : {}, clips: { upsert: available, remove: true, upsertRequires: 'current-production-binding' }, limits: { audioBytes: DOCUMENT_AUDIO_MAX_BYTES, requestBytes: DOCUMENT_AUDIO_REQUEST_MAX_BYTES, receipts: MAX_RECEIPTS, concurrent: MAX_CONCURRENT }, workspaceFileScope: 'same-document', idempotency: 'process-current-source', review: reviewRequired ? 'required-unsupported' : revisions ? 'locks-and-history' : 'locks-only' } satisfies DocumentAudioCapabilities
|
|
331
|
+
}),
|
|
332
|
+
inspect: (document, request, member, signal) => work(member, signal, async signal => {
|
|
333
|
+
if (!validateDocumentAudioInspect(request)) throw new RequestError(400, 'document-audio-query-invalid')
|
|
334
|
+
const ctx = ctxOf(document, member), current = opened(ctx, request.source.version, request.source), asset = current.content.assets[request.assetId]
|
|
335
|
+
if (!asset) throw new RequestError(404, 'production.asset-missing')
|
|
336
|
+
const inspection = await inspect(ctx, asset, request.source, signal)
|
|
337
|
+
return { document, version: request.source.version, fingerprint: request.source.fingerprint, inspection } satisfies DocumentAudioInspectionAnswer
|
|
338
|
+
}),
|
|
339
|
+
inspectWorkspace: (document, request, member, signal) => work(member, signal, async signal => {
|
|
340
|
+
if (!validateDocumentWorkspaceAudioInspect(request)) throw new RequestError(400, 'document-audio-query-invalid')
|
|
341
|
+
workspacePath(document, request.path, member)
|
|
342
|
+
const ctx = ctxOf(document, member)
|
|
343
|
+
opened(ctx, request.source.version, request.source)
|
|
344
|
+
const [bytes, found] = await Promise.all([reader(ctx, request.path, signal), resolved(document, request.source, signal)])
|
|
345
|
+
if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
|
|
346
|
+
const probed = await found.audio.inspectBytes('workspace-audio-probe', bytes, signal)
|
|
347
|
+
const { assetId: _assetId, mediaId: _mediaId, ...inspection } = probed.inspection
|
|
348
|
+
if (inspection.sha256 !== hash(bytes) || inspection.byteLength !== bytes.length) throw new RequestError(409, 'audio-inspection-changed')
|
|
349
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
350
|
+
return { document, source: request.source, file: { path: request.path, sha256: inspection.sha256, byteLength: inspection.byteLength }, inspection } satisfies WorkspaceAudioInspectionAnswer
|
|
351
|
+
}),
|
|
352
|
+
readProductionPlan: (document, request, member, signal) => work(member, signal, async signal => {
|
|
353
|
+
if (!validateDocumentProductionPlanQuery(request)) throw new RequestError(400, 'production-query-invalid')
|
|
354
|
+
const ctx = ctxOf(document, member)
|
|
355
|
+
opened(ctx, request.source.version, request.source)
|
|
356
|
+
const found = await resolved(document, request.source, signal)
|
|
357
|
+
if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
|
|
358
|
+
const plan = await found.audio.readPlan(request, signal)
|
|
359
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
360
|
+
if (!sameSource(plan.binding.source, request.source) || request.irRevision !== undefined && plan.binding.irRevision !== request.irRevision) throw new RequestError(409, 'production-binding-changed')
|
|
361
|
+
return plan
|
|
362
|
+
}),
|
|
363
|
+
checkWorkspaceImport(document, request, member, signal) {
|
|
364
|
+
const { ctx, title, change } = workspaceImport(document, request, member)
|
|
365
|
+
return work(member, signal, async signal => {
|
|
366
|
+
if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
|
|
367
|
+
const key = importKey(ctx, request.id)
|
|
368
|
+
if (known(ctx, importSignature(change, request.source, title, request.file.sha256, request.file.byteLength), receipts.get(key), imports.get(key))) return
|
|
369
|
+
opened(ctx, request.source.version, request.source)
|
|
370
|
+
const [bytes, found] = await Promise.all([reader(ctx, request.file.path, signal), resolved(document, request.source, signal)])
|
|
371
|
+
if (bytes.length !== request.file.byteLength || hash(bytes) !== request.file.sha256) throw new RequestError(409, 'audio-file-changed')
|
|
372
|
+
if (!found.audio) throw new RequestError(503, 'production-audio-unavailable')
|
|
373
|
+
await found.audio.inspectBytes('workspace-audio-probe', bytes, signal)
|
|
374
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
375
|
+
})
|
|
376
|
+
},
|
|
377
|
+
importWorkspace(document, request, member, signal) {
|
|
378
|
+
const { ctx, title, change } = workspaceImport(document, request, member)
|
|
379
|
+
return work(member, signal, signal => once(ctx, change, request.source, title, request.file.sha256, request.file.byteLength, 'agent', async () => {
|
|
380
|
+
opened(ctx, request.source.version, request.source)
|
|
381
|
+
const [bytes, found] = await Promise.all([reader(ctx, request.file.path, signal), resolved(document, request.source, signal)])
|
|
382
|
+
if (bytes.length !== request.file.byteLength || hash(bytes) !== request.file.sha256) throw new RequestError(409, 'audio-file-changed')
|
|
383
|
+
return importBytes(ctx, change, title, bytes, request.source, found, 'agent', signal)
|
|
384
|
+
}))
|
|
385
|
+
},
|
|
386
|
+
validateClipChange: (document, request, member, signal) => work(member, signal, async signal => {
|
|
387
|
+
if (!validateDocumentMediaClipValidation(request)) throw new RequestError(400, 'document-media-clip-invalid')
|
|
388
|
+
if (Buffer.byteLength(JSON.stringify(request)) > DOCUMENT_AUDIO_REQUEST_MAX_BYTES) throw new RequestError(413, 'document-audio-request-size')
|
|
389
|
+
const ctx = ctxOf(document, member), prepared = prepareClip(ctx, request)
|
|
390
|
+
await prepared.validate(signal)
|
|
391
|
+
await editor.planTransaction(scopeOf(document), [{ path: prepared.current.source.path, content: prepared.content, base: request.source.version }], member)
|
|
392
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
393
|
+
return prepared.answer
|
|
394
|
+
}),
|
|
395
|
+
checkClipChange: (document, request, member, signal) => work(member, signal, async signal => {
|
|
396
|
+
const ctx = ctxOf(document, member), { key, signature } = clipIntent(ctx, request, 'agent')
|
|
397
|
+
if (known(ctx, signature, receipts.get(key), clipChanges.get(key))) return
|
|
398
|
+
const prepared = prepareClip(ctx, request)
|
|
399
|
+
await prepared.validate(signal)
|
|
400
|
+
await editor.planTransaction(scopeOf(document), [{ path: prepared.current.source.path, content: prepared.content, base: request.source.version }], member)
|
|
401
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
402
|
+
}),
|
|
403
|
+
checkRecordingAdoption: (document, request, member, signal) => work(member, signal, async signal => {
|
|
404
|
+
const ctx = ctxOf(document, member), { key, signature } = recordingIntent(ctx, request, 'agent')
|
|
405
|
+
if (known(ctx, signature, receipts.get(key), recordingChanges.get(key))) return
|
|
406
|
+
const prepared = await prepareRecording(ctx, request, signal)
|
|
407
|
+
await editor.planTransaction(scopeOf(document), [{ path: prepared.current.source.path, content: prepared.content, base: request.source.version }], member)
|
|
408
|
+
check(signal); opened(ctx, request.source.version, request.source)
|
|
409
|
+
}),
|
|
410
|
+
changeClip: (document, request, member, signal) => work(member, signal, signal => changeClip(ctxOf(document, member), request, 'agent', signal)),
|
|
411
|
+
adoptRecording: (document, request, member, signal) => work(member, signal, signal => adoptRecording(ctxOf(document, member), request, 'agent', signal)),
|
|
412
|
+
}
|
|
413
|
+
const guarded = (run: () => Promise<Response>) => catchRequestErrors(async () => { try { return await run() } catch (error) { if (error instanceof ProductionAudioRefusal) throw new RequestError(error.status, error.code); throw error } })
|
|
414
|
+
const routes: Record<string, Route> = {
|
|
415
|
+
'document/recording/adopt': { role: 'editor', maxBody: DOCUMENT_AUDIO_REQUEST_MAX_BYTES, handle: (request, ctx) => guarded(() => work(ctx.member, request.signal, async signal => {
|
|
416
|
+
if (request.method !== 'POST') throw new RequestError(405, 'POST')
|
|
417
|
+
return jsonResponse(await adoptRecording(ctx, await request.json().catch(() => undefined), 'editor', signal))
|
|
418
|
+
})) },
|
|
419
|
+
'document/audio': { role: 'editor', maxBody: 17 << 20, handle: (request, ctx) => guarded(() => work(ctx.member, request.signal, async signal => {
|
|
420
|
+
if (request.method === 'GET') {
|
|
421
|
+
const params = new URL(request.url).searchParams, version = params.get('baseDocumentVersion'), id = params.get('assetId')
|
|
422
|
+
if (!version || !id || id.length > 200) throw new RequestError(400, 'document-audio-query-invalid')
|
|
423
|
+
const current = opened(ctx, version), asset = current.content.assets[id]
|
|
424
|
+
if (!asset) throw new RequestError(404, 'production.asset-missing')
|
|
425
|
+
return jsonResponse({ document: ctx.document, version, fingerprint: fnv1a64(current.source.content), inspection: await inspect(ctx, asset, binding(current), signal) } satisfies DocumentAudioInspectionAnswer)
|
|
426
|
+
}
|
|
427
|
+
if (request.method !== 'POST') throw new RequestError(405, 'GET or POST')
|
|
428
|
+
const form = await request.formData().catch(() => undefined)
|
|
429
|
+
if (!form || [...form.keys()].some(key => key !== 'change' && key !== 'file') || form.getAll('change').length !== 1 || form.getAll('file').length !== 1) throw new RequestError(400, 'document-audio-form-invalid')
|
|
430
|
+
const text = form.get('change'), file = form.get('file')
|
|
431
|
+
if (typeof text !== 'string' || text.length > 4096 || !(file instanceof File)) throw new RequestError(400, 'document-audio-form-invalid')
|
|
432
|
+
let change: unknown
|
|
433
|
+
try { change = JSON.parse(text) } catch { throw new RequestError(400, 'document-audio-change-invalid') }
|
|
434
|
+
if (!header(change) || Object.keys(change).some(key => key !== 'id' && key !== 'baseDocumentVersion')) throw new RequestError(400, 'document-audio-change-invalid')
|
|
435
|
+
if (!file.size || file.size > DOCUMENT_AUDIO_MAX_BYTES) throw new RequestError(413, 'production.asset-size')
|
|
436
|
+
const title = titleOf(file.name), bytes = new Uint8Array(await file.arrayBuffer())
|
|
437
|
+
const key = JSON.stringify(['audio', ctx.document, ctx.member.id, change.id])
|
|
438
|
+
const source = receipts.get(key)?.source ?? imports.get(key)?.source ?? binding(opened(ctx, change.baseDocumentVersion))
|
|
439
|
+
const answer = await once(ctx, change, source, title, hash(bytes), bytes.length, 'editor', async () => {
|
|
440
|
+
opened(ctx, change.baseDocumentVersion, source)
|
|
441
|
+
const found = await resolved(ctx.document, source, signal)
|
|
442
|
+
return importBytes(ctx, change, title, bytes, source, found, 'editor', signal)
|
|
443
|
+
})
|
|
444
|
+
return jsonResponse(answer)
|
|
445
|
+
})) },
|
|
446
|
+
'document/media-clip': {
|
|
447
|
+
role: 'editor', maxBody: 1 << 20,
|
|
448
|
+
handle: (request, ctx) => guarded(() => work(ctx.member, request.signal, async signal => {
|
|
449
|
+
if (request.method !== 'POST') throw new RequestError(405, 'POST')
|
|
450
|
+
const raw: unknown = await request.json().catch(() => undefined)
|
|
451
|
+
if (!isRecord(raw) || !header(raw)) throw new RequestError(400, 'document-media-clip-invalid')
|
|
452
|
+
const fields = raw as Record<string, unknown>
|
|
453
|
+
if ((fields.clip === undefined) === (fields.removeClipId === undefined)) throw new RequestError(400, 'document-media-clip-invalid')
|
|
454
|
+
const upsert = fields.clip !== undefined, allowed = new Set(['id', 'baseDocumentVersion', ...upsert ? ['binding', 'clip'] : ['removeClipId']])
|
|
455
|
+
if (Object.keys(fields).some(key => !allowed.has(key))) throw new RequestError(400, 'document-media-clip-invalid')
|
|
456
|
+
const previous = receipts.get(JSON.stringify(['clip', ctx.document, ctx.member.id, raw.id]))
|
|
457
|
+
const source = upsert && isRecord(fields.binding) ? fields.binding.source : previous?.source?.version === raw.baseDocumentVersion ? previous.source : binding(opened(ctx, raw.baseDocumentVersion))
|
|
458
|
+
if (!isRecord(source) || source.version !== raw.baseDocumentVersion) throw new RequestError(409, 'production-binding-changed')
|
|
459
|
+
const change = { id: raw.id, source, ...upsert ? { binding: fields.binding, clip: fields.clip } : { removeClipId: fields.removeClipId } }
|
|
460
|
+
if (!validateDocumentMediaClipEdit(change)) throw new RequestError(400, 'document-media-clip-invalid')
|
|
461
|
+
return jsonResponse(await changeClip(ctx, change, 'editor', signal))
|
|
462
|
+
})),
|
|
463
|
+
},
|
|
464
|
+
'document/media-clip/validate': {
|
|
465
|
+
role: 'editor', maxBody: DOCUMENT_AUDIO_REQUEST_MAX_BYTES,
|
|
466
|
+
handle: (request, ctx) => guarded(async () => {
|
|
467
|
+
if (request.method !== 'POST') throw new RequestError(405, 'POST')
|
|
468
|
+
return jsonResponse(await service.validateClipChange(ctx.document, await request.json().catch(() => undefined), ctx.member, request.signal))
|
|
469
|
+
}),
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
return { service, routes, async close() { closed = true; for (const operation of operations) operation.controller.abort(); await Promise.allSettled([...operations].map(operation => operation.finished)); receipts.clear() } }
|
|
473
|
+
}
|