@lincyaw/kernel 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.
Files changed (119) hide show
  1. package/README.md +7 -7
  2. package/package.json +6 -2
  3. package/src/access.ts +18 -0
  4. package/src/anchors.ts +5 -1
  5. package/src/authoring.ts +56 -0
  6. package/src/blocks.ts +0 -8
  7. package/src/component-code.ts +6 -0
  8. package/src/contracts/agent.ts +5 -82
  9. package/src/contracts/anchors.ts +46 -0
  10. package/src/contracts/authoring-actions.ts +60 -0
  11. package/src/contracts/authoring-clients.ts +213 -0
  12. package/src/contracts/authoring.ts +180 -0
  13. package/src/contracts/checks.ts +29 -0
  14. package/src/contracts/classroom.ts +49 -0
  15. package/src/contracts/diagnostics.ts +37 -0
  16. package/src/contracts/document-audio.ts +150 -0
  17. package/src/contracts/document-comments.ts +31 -0
  18. package/src/contracts/document-history.ts +63 -0
  19. package/src/contracts/document-image.ts +32 -0
  20. package/src/contracts/document-reads.ts +57 -0
  21. package/src/contracts/document-voice.ts +44 -0
  22. package/src/contracts/document.ts +240 -0
  23. package/src/contracts/execution.ts +4 -5
  24. package/src/contracts/locale.ts +3 -0
  25. package/src/contracts/offline.ts +2 -0
  26. package/src/contracts/plugin.ts +183 -0
  27. package/src/contracts/presenter-panels.ts +17 -0
  28. package/src/contracts/preview.ts +305 -310
  29. package/src/contracts/production-assets.ts +61 -0
  30. package/src/contracts/production.ts +109 -0
  31. package/src/contracts/projects.ts +59 -0
  32. package/src/contracts/server.ts +1 -51
  33. package/src/contracts/speech-settings.ts +16 -0
  34. package/src/contracts/speech.ts +82 -70
  35. package/src/contracts/steps.ts +15 -0
  36. package/src/contracts/transcription-adoption.ts +79 -0
  37. package/src/contracts/transcription.ts +112 -0
  38. package/src/contracts/voice.ts +97 -0
  39. package/src/contracts/voiceover-execution.ts +68 -0
  40. package/src/contracts/workspace.ts +458 -0
  41. package/src/contracts.ts +28 -278
  42. package/src/diagnostics.ts +7 -0
  43. package/src/document-authoring-actions.ts +47 -0
  44. package/src/document-caption-timings.ts +64 -0
  45. package/src/document-colors.ts +25 -0
  46. package/src/document-comments.ts +118 -0
  47. package/src/document-components.ts +251 -0
  48. package/src/document-operations.ts +132 -0
  49. package/src/document-pages.ts +215 -0
  50. package/src/document-reads.ts +81 -0
  51. package/src/document-schema.ts +85 -0
  52. package/src/document-voices.ts +153 -0
  53. package/src/document.ts +252 -0
  54. package/src/durations.ts +7 -0
  55. package/src/errors.ts +8 -0
  56. package/src/execution.ts +5 -1
  57. package/src/fingerprint.ts +1 -1
  58. package/src/follow.ts +26 -30
  59. package/src/git.ts +1 -1
  60. package/src/index.ts +66 -13
  61. package/src/ir.ts +54 -5
  62. package/src/jupyter-kernel.ts +9 -0
  63. package/src/kernel.ts +38 -25
  64. package/src/keyboard.ts +43 -0
  65. package/src/locale.ts +13 -0
  66. package/src/lock.ts +4 -8
  67. package/src/logging.ts +1 -1
  68. package/src/{markdown.ts → lower-myst.ts} +2 -1
  69. package/src/node/assets.ts +23 -0
  70. package/src/node/authoring-actions.ts +50 -0
  71. package/src/node/authoring-clients.ts +14 -0
  72. package/src/node/document-audio.ts +22 -0
  73. package/src/node/document-history.ts +11 -0
  74. package/src/node/document-image.ts +8 -0
  75. package/src/node/document-reads.ts +10 -0
  76. package/src/node/document-voice.ts +7 -0
  77. package/src/node/ffmpeg.ts +29 -0
  78. package/src/node/file-version.ts +6 -0
  79. package/src/node/fonts.ts +22 -0
  80. package/src/node/index.ts +29 -133
  81. package/src/node/installed-packages.ts +11 -0
  82. package/src/node/lines.ts +24 -0
  83. package/src/node/locale.ts +7 -0
  84. package/src/node/network.ts +11 -0
  85. package/src/node/operator-speech.ts +91 -0
  86. package/src/node/packages.ts +74 -0
  87. package/src/node/paths.ts +7 -0
  88. package/src/node/projects.ts +7 -0
  89. package/src/node/resources.ts +86 -0
  90. package/src/node/revision.ts +6 -0
  91. package/src/node/run-directory.ts +70 -0
  92. package/src/node/transcription-adoption.ts +9 -0
  93. package/src/node/transcription.ts +10 -0
  94. package/src/node/voiceover-routes.ts +73 -0
  95. package/src/node/voiceover.ts +199 -0
  96. package/src/page-source.ts +359 -0
  97. package/src/parameters.ts +81 -0
  98. package/src/preview-edit.ts +18 -0
  99. package/src/preview-events.ts +57 -0
  100. package/src/preview-frame.ts +1220 -0
  101. package/src/props.ts +17 -0
  102. package/src/push-stream.ts +67 -0
  103. package/src/reading.ts +12 -7
  104. package/src/recording.ts +38 -0
  105. package/src/renderer-components.ts +30 -0
  106. package/src/responses.ts +49 -0
  107. package/src/run-outputs.ts +15 -0
  108. package/src/runner.py +3 -3
  109. package/src/services.ts +6 -6
  110. package/src/slides.ts +36 -0
  111. package/src/speech-words.ts +76 -0
  112. package/src/speech.ts +13 -0
  113. package/src/step-keys.ts +38 -0
  114. package/src/steps.ts +7 -0
  115. package/src/transcription-adoption.ts +105 -0
  116. package/src/uri-decoding.ts +8 -0
  117. package/src/urls.ts +13 -0
  118. package/src/views.ts +47 -0
  119. package/src/voice.ts +86 -0
@@ -0,0 +1,86 @@
1
+ import { spawn } from 'node:child_process'
2
+ import type { ChildProcess, SpawnOptions } from 'node:child_process'
3
+ import { availableParallelism, constants, freemem, loadavg, setPriority } from 'node:os'
4
+ import { TYPESCRIPT_IMPORT } from './packages.ts'
5
+
6
+ const THREAD_VARIABLES = ['GOMAXPROCS', 'RAYON_NUM_THREADS', 'OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'BLIS_NUM_THREADS', 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_MAX_THREADS', 'NUMEXPR_NUM_THREADS', 'POLARS_MAX_THREADS']
7
+ export const NODE_RESOURCE_ARGS: readonly string[] = ['--v8-pool-size=1']
8
+
9
+ /**
10
+ * `interactive`: what the author waits for, the workspace service and the previews' dev servers; `background`: what
11
+ * runs while the author does something else, builds, exports and code runs.
12
+ */
13
+ export type ResourceClass = 'interactive' | 'background'
14
+
15
+ /** What the machine has now: its processors, how many of them are busy (the one-minute load), and its free memory. */
16
+ export interface MachineState {
17
+ cpus: number
18
+ load: number
19
+ free: number
20
+ }
21
+
22
+ export function machineState(): MachineState {
23
+ return { cpus: availableParallelism(), load: loadavg()[0] ?? 0, free: freemem() }
24
+ }
25
+
26
+ // Bundlers gain little from more threads than this, and each thread holds memory.
27
+ const INTERACTIVE_MAX = 8
28
+ // Below this much free memory an interactive child takes two threads at most.
29
+ const LOW_MEMORY = 2 * 1024 ** 3
30
+
31
+ /**
32
+ * The native threads a child may use. A background child keeps to the operator's `PRESMIUM_MAX_THREADS`, one by default
33
+ * and at most half the processors. An interactive child takes three quarters of the processors that are idle now (all
34
+ * of them where the load is not known, as on Windows), keeping one for the rest of the machine, at most eight, and two
35
+ * when memory is short; `PRESMIUM_MAX_THREADS` caps it.
36
+ */
37
+ export function threadBudget(kind: ResourceClass, source: NodeJS.ProcessEnv = process.env, machine: MachineState = machineState()): number {
38
+ const requested = Number(source.PRESMIUM_MAX_THREADS)
39
+ const operator = Number.isInteger(requested) && requested > 0 ? requested : undefined
40
+ if (kind === 'background') return Math.min(operator ?? 1, Math.max(1, Math.floor(machine.cpus / 2)))
41
+ const idle = Math.max(1, machine.cpus - Math.min(machine.load, machine.cpus))
42
+ let threads = Math.max(1, Math.min(Math.floor(idle * 0.75), machine.cpus - 1, INTERACTIVE_MAX))
43
+ if (machine.free < LOW_MEMORY) threads = Math.min(threads, 2)
44
+ return operator === undefined ? threads : Math.min(threads, operator)
45
+ }
46
+
47
+ /** Bound native thread pools as well as JS jobs; leave CPU capacity for other apps. */
48
+ export function resourceEnvironment(source: NodeJS.ProcessEnv = process.env, kind: ResourceClass = 'background', machine?: MachineState): Record<string, string> {
49
+ const env = Object.fromEntries(Object.entries(source).filter((entry): entry is [string, string] => entry[1] !== undefined))
50
+ const threads = threadBudget(kind, source, machine)
51
+ if (source.PRESMIUM_MAX_THREADS !== undefined && kind === 'background') env.PRESMIUM_MAX_THREADS = String(threads)
52
+ for (const name of THREAD_VARIABLES) {
53
+ const current = Number(source[name])
54
+ // An interactive child takes its own budget: a smaller value it inherited came from a background parent's limit.
55
+ env[name] = String(kind === 'background' && Number.isInteger(current) && current > 0 ? Math.min(current, threads) : threads)
56
+ }
57
+ const ioThreads = Number(source.UV_THREADPOOL_SIZE)
58
+ env.UV_THREADPOOL_SIZE = kind === 'interactive'
59
+ ? String(Math.max(4, threads))
60
+ : String(Number.isInteger(ioThreads) && ioThreads > 0 ? Math.min(ioThreads, 2) : 2)
61
+ return env
62
+ }
63
+
64
+ /** Node's own arguments for a child: V8's background pool as large as its thread budget. */
65
+ export function nodeResourceArgs(kind: ResourceClass, source: NodeJS.ProcessEnv = process.env, machine?: MachineState): string[] {
66
+ return kind === 'background' ? [...NODE_RESOURCE_ARGS] : [`--v8-pool-size=${threadBudget(kind, source, machine)}`]
67
+ }
68
+
69
+ /** Child priorities are best effort on platforms that restrict changing niceness. */
70
+ export function backgroundPriority(pid: number | undefined): void {
71
+ if (pid === undefined) return
72
+ try { setPriority(pid, constants.priority.PRIORITY_BELOW_NORMAL) } catch {}
73
+ }
74
+
75
+ /** Starts Node on `script` as a background child: one V8 worker, bounded thread pools, TypeScript imports and a lower priority. */
76
+ export function spawnBackgroundNode(script: string, args: readonly string[], options: SpawnOptions = {}): ChildProcess {
77
+ const child = spawn(process.execPath, [...NODE_RESOURCE_ARGS, ...TYPESCRIPT_IMPORT, script, ...args], { env: resourceEnvironment(), ...options })
78
+ backgroundPriority(child.pid)
79
+ return child
80
+ }
81
+
82
+ /** Starts Node on `script` as a child the author waits for: the interactive thread budget at normal priority, with TypeScript imports. */
83
+ export function spawnInteractiveNode(script: string, args: readonly string[], options: SpawnOptions = {}): ChildProcess {
84
+ const machine = machineState()
85
+ return spawn(process.execPath, [...nodeResourceArgs('interactive', process.env, machine), ...TYPESCRIPT_IMPORT, script, ...args], { env: resourceEnvironment(process.env, 'interactive', machine), ...options })
86
+ }
@@ -0,0 +1,6 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { IRDocument } from '../ir.ts'
3
+
4
+ export function irRevision(doc: IRDocument): string {
5
+ return createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16)
6
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import type { ProcessSandbox, SandboxResult, SandboxRun } from '../contracts/execution.ts'
6
+ import { backgroundPriority, resourceEnvironment } from './resources.ts'
7
+
8
+ /** The runner reads its requests from this file of the run directory (`runner.py`, the script and WASI runners). */
9
+ export const RUN_REQUEST_FILE = '.presmium/request.json'
10
+
11
+ export const RUN_RESULT_PREFIX = '.presmium/result-'
12
+
13
+ /** The runner writes the result of the request at this index here, once it is complete. */
14
+ export function runResultFile(index: number): string {
15
+ return `${RUN_RESULT_PREFIX}${index}.json`
16
+ }
17
+
18
+ /** The environment of a run that is given none: `PATH`, `LANG` and a temporary `HOME`, no credentials. */
19
+ export function minimalRunEnv(): Record<string, string> {
20
+ const home = join(tmpdir(), 'presmium-home')
21
+ mkdirSync(home, { recursive: true })
22
+ return { PATH: process.env.PATH ?? '', LANG: process.env.LANG ?? 'C.UTF-8', HOME: home }
23
+ }
24
+
25
+ /** The process sandbox when none is configured: runs the command directly, in a new temporary run directory. */
26
+ export const directSandbox: ProcessSandbox = {
27
+ kind: 'none',
28
+ run(request: SandboxRun): Promise<SandboxResult> {
29
+ const dir = mkdtempSync(join(tmpdir(), 'presmium-run-'))
30
+ for (const [path, content] of Object.entries(request.files)) {
31
+ mkdirSync(dirname(join(dir, path)), { recursive: true })
32
+ writeFileSync(join(dir, path), content)
33
+ }
34
+ return new Promise((resolve) => {
35
+ const [command, ...args] = request.argv
36
+ const child = spawn(command!, args, { cwd: dir, env: resourceEnvironment(request.env), stdio: ['ignore', 'pipe', 'pipe'] })
37
+ backgroundPriority(child.pid)
38
+ const out: Buffer[] = []
39
+ const err: Buffer[] = []
40
+ let stopped = false
41
+ const stop = () => {
42
+ stopped = true
43
+ child.kill('SIGKILL')
44
+ }
45
+ const timer = setTimeout(stop, request.timeoutMs)
46
+ if (request.signal?.aborted) stop()
47
+ request.signal?.addEventListener('abort', stop, { once: true })
48
+ child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
49
+ child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
50
+ let settled = false
51
+ const finish = (exitCode: number | null, error?: Error) => {
52
+ if (settled) return
53
+ settled = true
54
+ clearTimeout(timer)
55
+ request.signal?.removeEventListener('abort', stop)
56
+ const files: Record<string, Uint8Array> = {}
57
+ for (const path of request.collect) {
58
+ try {
59
+ files[path] = readFileSync(join(dir, path))
60
+ } catch {}
61
+ }
62
+ rmSync(dir, { recursive: true, force: true })
63
+ const stderr = Buffer.concat(err).toString('utf8').replaceAll(dir, '.')
64
+ resolve({ exitCode: stopped ? null : exitCode, stdout: Buffer.concat(out).toString('utf8'), stderr: error ? `${stderr}${error.message}` : stderr, files })
65
+ }
66
+ child.on('error', error => finish(null, error))
67
+ child.on('close', code => finish(code))
68
+ })
69
+ },
70
+ }
@@ -0,0 +1,9 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { TRANSCRIPTION_ADOPTION_APPLY_SCHEMA, TRANSCRIPTION_ADOPTION_DRAFT_SCHEMA } from '../contracts/transcription-adoption.ts'
4
+ import type { TranscriptionAdoptionApply, TranscriptionAdoptionDraft } from '../contracts/transcription-adoption.ts'
5
+
6
+ const draft = new Validator(structuredClone(TRANSCRIPTION_ADOPTION_DRAFT_SCHEMA) as unknown as Schema, '2020-12', false)
7
+ const apply = new Validator(structuredClone(TRANSCRIPTION_ADOPTION_APPLY_SCHEMA) as unknown as Schema, '2020-12', false)
8
+ export function validateTranscriptionAdoptionDraft(value: unknown): value is TranscriptionAdoptionDraft { return draft.validate(value).valid }
9
+ export function validateTranscriptionAdoptionApply(value: unknown): value is TranscriptionAdoptionApply { return apply.validate(value).valid }
@@ -0,0 +1,10 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { DOCUMENT_TRANSCRIPTION_REQUEST_SCHEMA } from '../contracts/transcription.ts'
4
+ import type { DocumentTranscriptionRequest } from '../contracts/transcription.ts'
5
+
6
+ const requestValidator = new Validator(structuredClone(DOCUMENT_TRANSCRIPTION_REQUEST_SCHEMA) as unknown as Schema, '2020-12', false)
7
+
8
+ export function validateDocumentTranscriptionRequest(value: unknown): value is DocumentTranscriptionRequest {
9
+ return requestValidator.validate(value).valid
10
+ }
@@ -0,0 +1,73 @@
1
+ import { errorMessage, hasRole, isRecord, jsonResponse } from '../index.ts'
2
+ import type { DocumentVoiceTasks, VoicePreviewCheck, VoicePreviewStart, VoiceoverStart } from '../contracts/voiceover-execution.ts'
3
+ import type { Route } from '../contracts/server.ts'
4
+ import { validateVoiceoverJobQuery, validateVoiceoverStart, validateVoicePreviewCheck, validateVoicePreviewStart, VoiceoverOperationError } from './voiceover.ts'
5
+
6
+ /** The legacy Web wire shapes adapt to the same author service used by MCP. */
7
+ export function createVoiceoverRoutes(service: DocumentVoiceTasks): Record<string, Route> {
8
+ const route = (handle: Route['handle']): Route => ({ role: 'editor', maxBody: 64 << 10, async handle(request, ctx) {
9
+ try {
10
+ if (!hasRole(ctx.member, 'editor')) throw new VoiceoverOperationError(403, 'voice-authoring-permission-denied')
11
+ return await handle(request, ctx)
12
+ } catch (error) {
13
+ const status = isRecord(error) && typeof error.status === 'number' ? error.status : 502
14
+ return jsonResponse({ error: errorMessage(error) }, status)
15
+ }
16
+ } })
17
+ const body = async (request: Request) => await request.json().catch(() => undefined) as unknown
18
+ const confirmed = (value: unknown) => {
19
+ if (!isRecord(value)) throw new VoiceoverOperationError(400, 'voice-generation-invalid')
20
+ if (value.confirmed !== true) throw new VoiceoverOperationError(409, 'voice-confirmation-required')
21
+ const { confirmed: _confirmed, ...request } = value
22
+ return request
23
+ }
24
+ return {
25
+ voiceover: route(async (request, ctx) => {
26
+ if (request.method === 'GET') return jsonResponse(await service.snapshot(ctx.document, {}, ctx.member, request.signal))
27
+ if (request.method !== 'POST') throw new VoiceoverOperationError(405, 'GET or POST')
28
+ const value = confirmed(await body(request))
29
+ if (!validateVoiceoverStart(value)) throw new VoiceoverOperationError(400, 'voice-generation-invalid')
30
+ return jsonResponse(await service.start(ctx.document, value as VoiceoverStart, ctx.member, request.signal), 202)
31
+ }),
32
+ 'voiceover/jobs': route(async (request, ctx) => {
33
+ if (request.method !== 'GET') throw new VoiceoverOperationError(405, 'GET')
34
+ return jsonResponse(await service.listJobs(ctx.document, ctx.member))
35
+ }),
36
+ 'voiceover/job': route(async (request, ctx) => {
37
+ if (request.method !== 'GET') throw new VoiceoverOperationError(405, 'GET')
38
+ const query = Object.fromEntries([...new URL(request.url).searchParams].filter(([key]) => key !== 'document'))
39
+ if (!validateVoiceoverJobQuery(query)) throw new VoiceoverOperationError(400, 'voice-job-query-invalid')
40
+ return jsonResponse(await service.getJob(ctx.document, query, ctx.member))
41
+ }),
42
+ 'voiceover/cancel': route(async (request, ctx) => {
43
+ if (request.method !== 'POST') throw new VoiceoverOperationError(405, 'POST')
44
+ const value = await body(request)
45
+ if (!validateVoiceoverJobQuery(value)) throw new VoiceoverOperationError(400, 'voice-cancel-invalid')
46
+ const answer = await service.cancel(ctx.document, value, ctx.member)
47
+ return jsonResponse('id' in value ? answer.job : answer)
48
+ }),
49
+ 'voice-preview': route(async (request, ctx) => {
50
+ if (request.method === 'GET') {
51
+ const params = new URL(request.url).searchParams
52
+ let voice: unknown, target: unknown, source: unknown
53
+ try { voice = JSON.parse(params.get('voice') ?? 'null'); target = JSON.parse(params.get('target') ?? 'null'); source = JSON.parse(params.get('source') ?? 'null') }
54
+ catch { throw new VoiceoverOperationError(400, 'voice-preview-invalid') }
55
+ const query = { revision: params.get('revision'), voice, mode: params.get('mode'), ...target !== null ? { target } : {}, ...source !== null ? { source } : {} }
56
+ if (!validateVoicePreviewCheck(query)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
57
+ return jsonResponse(await service.quotePreview(ctx.document, query as VoicePreviewCheck, ctx.member, request.signal))
58
+ }
59
+ if (request.method !== 'POST') throw new VoiceoverOperationError(405, 'GET or POST')
60
+ const value = confirmed(await body(request))
61
+ if (!validateVoicePreviewStart(value)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
62
+ const result = await service.preview(ctx.document, value as VoicePreviewStart, ctx.member, request.signal)
63
+ const { resource, bytes } = await service.readPreview(ctx.document, result.id, ctx.member)
64
+ const { id: _id, byteLength: _length, sha256: _sha, mediaType, ...answer } = resource
65
+ return jsonResponse({ ...answer, audio: `data:${mediaType};base64,${Buffer.from(bytes).toString('base64')}` })
66
+ }),
67
+ 'voice-preview/media': route(async (request, ctx) => {
68
+ if (request.method !== 'GET') throw new VoiceoverOperationError(405, 'GET')
69
+ const id = new URL(request.url).searchParams.get('id') ?? '', result = await service.readPreview(ctx.document, id, ctx.member)
70
+ return new Response(new Uint8Array(result.bytes), { headers: { 'Content-Type': result.resource.mediaType, 'Content-Length': String(result.bytes.byteLength), 'Cache-Control': 'no-store' } })
71
+ }),
72
+ }
73
+ }
@@ -0,0 +1,199 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { Validator } from '@cfworker/json-schema'
3
+ import type { Schema } from '@cfworker/json-schema'
4
+ import { VOICEOVER_JOB_QUERY_SCHEMA, VOICEOVER_START_SCHEMA, VOICEOVER_STATUS_SCHEMA, VOICE_PREVIEW_CHECK_SCHEMA, VOICE_PREVIEW_START_SCHEMA } from '../contracts/voiceover-execution.ts'
5
+ import type { VoiceoverExecution, VoiceoverJobQuery, VoiceoverObservation, VoiceoverStart, VoiceoverStatusQuery, VoicePreviewCheck, VoicePreviewResource, VoicePreviewResourceData, VoicePreviewStart } from '../contracts/voiceover-execution.ts'
6
+ import type { CanonicalSource } from '../ir.ts'
7
+ import type { VoiceoverCancellation, VoiceoverJob, VoicePreviewQuote } from '../contracts/voice.ts'
8
+
9
+ const validator = <T>(schema: unknown) => { const checked = new Validator(structuredClone(schema) as Schema, '2020-12', false); return (value: unknown): value is T => checked.validate(value).valid }
10
+ export const validateVoiceoverStart = validator<VoiceoverStart>(VOICEOVER_START_SCHEMA)
11
+ export const validateVoiceoverStatus = validator<VoiceoverStatusQuery>(VOICEOVER_STATUS_SCHEMA)
12
+ export const validateVoiceoverJobQuery = validator<VoiceoverJobQuery>(VOICEOVER_JOB_QUERY_SCHEMA)
13
+ export const validateVoicePreviewCheck = validator<VoicePreviewCheck>(VOICE_PREVIEW_CHECK_SCHEMA)
14
+ export const validateVoicePreviewStart = validator<VoicePreviewStart>(VOICE_PREVIEW_START_SCHEMA)
15
+ export class VoiceoverOperationError extends Error {
16
+ readonly status: number
17
+ constructor(status: number, message: string) { super(message); this.status = status }
18
+ }
19
+ type Load = (source: CanonicalSource | undefined, signal: AbortSignal) => Promise<VoiceoverExecution>
20
+ interface ActiveJob { job: VoiceoverJob, controller: AbortController }
21
+ interface Intent { payload?: string, controller: AbortController, job?: ActiveJob, state: 'preparing' | 'cancelled' | 'failed', error?: string, finished: Promise<void>, finish(): void }
22
+ const MAX_INTENTS = 1024, RECENT_JOBS = 20, MAX_OPERATIONS = 4, MAX_QUOTES = 64, QUOTE_MS = 120_000, MAX_PREVIEWS = 8, MAX_MEDIA_BYTES = 16 << 20
23
+ const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
24
+ const targetKey = (target: { section: string, step: number, binding: string }) => [target.section, target.step, target.binding]
25
+ const sourceKey = (source?: CanonicalSource) => source && [source.documentId, source.version, source.fingerprint]
26
+ const startPayload = (request: VoiceoverStart) => digest([request.revision, sourceKey(request.source), request.scope.kind, request.scope.kind === 'page' ? request.scope.section : request.scope.kind === 'step' ? targetKey(request.scope.target) : request.scope.kind === 'steps' ? [...new Set(request.scope.targets.map(target => JSON.stringify(targetKey(target))))].sort() : []])
27
+ const previewPayload = (request: VoicePreviewCheck) => digest([sourceKey(request.source), request.revision, request.voice.engine, request.voice.voice, request.voice.rate, request.voice.style, request.mode, request.target && targetKey(request.target)])
28
+
29
+ /** The existing in-process intent/job coordinator, shared by Workspace and standalone lecture servers. */
30
+ export function createVoiceoverOwner() {
31
+ const jobs = new Map<string, ActiveJob>(), intents = new Map<string, Intent>()
32
+ const operations = new Set<{ controller: AbortController, finished: Promise<unknown> }>()
33
+ const quotes = new Map<string, { value: VoicePreviewQuote, underlying: string, execution: VoiceoverExecution, expires: number }>()
34
+ const media = new Map<string, VoicePreviewResourceData>()
35
+ let closed = false
36
+ const available = () => { if (closed) throw new VoiceoverOperationError(503, 'voice-service-closing') }
37
+ const work = <T>(signal: AbortSignal | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T> => {
38
+ available(); signal?.throwIfAborted()
39
+ if (operations.size >= MAX_OPERATIONS) throw new VoiceoverOperationError(503, 'voice-authoring-busy')
40
+ const controller = new AbortController(), combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
41
+ const operation = { controller, finished: Promise.resolve() as Promise<unknown> }
42
+ operations.add(operation)
43
+ const finished = Promise.resolve().then(() => { available(); combined.throwIfAborted(); return run(combined) }).catch(error => { if (closed) throw new VoiceoverOperationError(503, 'voice-service-closing'); throw error }).finally(() => operations.delete(operation))
44
+ operation.finished = finished
45
+ return finished
46
+ }
47
+ const patch = (job: VoiceoverJob, value: Partial<VoiceoverJob>) => {
48
+ if (Object.entries(value).some(([key, value]) => job[key as keyof VoiceoverJob] !== value)) { Object.assign(job, value); job.sequence++ }
49
+ }
50
+ const newIntent = (id: string, payload?: string): Intent => {
51
+ if (intents.size >= MAX_INTENTS) throw new VoiceoverOperationError(503, 'voice-intent-capacity')
52
+ let finish!: () => void
53
+ const finished = new Promise<void>(resolve => { finish = resolve })
54
+ const intent: Intent = { payload, controller: new AbortController(), state: 'preparing', finished, finish }
55
+ intents.set(id, intent)
56
+ return intent
57
+ }
58
+ const cancelJob = (entry: ActiveJob) => {
59
+ if (entry.job.state === 'queued' || entry.job.state === 'running') {
60
+ const queued = entry.job.state === 'queued'
61
+ patch(entry.job, { state: queued ? 'cancelled' : 'cancelling', ...queued ? { finishedAt: new Date().toISOString() } : {} })
62
+ entry.controller.abort(new Error('voice-cancelled'))
63
+ }
64
+ }
65
+ const find = (query: VoiceoverJobQuery): Intent | undefined => 'intent' in query ? intents.get(query.intent) : [...intents.values()].find(intent => intent.job?.job.id === query.id)
66
+ const observation = (id: string, intent: Intent): VoiceoverObservation => ({ intent: id, state: intent.job?.job.state ?? (intent.state === 'preparing' ? 'queued' : intent.state), ...intent.job ? { job: structuredClone(intent.job.job) } : {}, ...intent.error ? { error: intent.error } : {} })
67
+ return {
68
+ limits: { intents: MAX_INTENTS, recentJobs: RECENT_JOBS, concurrent: MAX_OPERATIONS, quotes: MAX_QUOTES, quoteLifetimeMs: QUOTE_MS, previewBytes: 8 << 20, previewResources: MAX_PREVIEWS, retainedPreviewBytes: MAX_MEDIA_BYTES },
69
+ snapshot(query: VoiceoverStatusQuery, load: Load, signal?: AbortSignal) {
70
+ if (!validateVoiceoverStatus(query)) throw new VoiceoverOperationError(400, 'voice-status-invalid')
71
+ return work(signal, async signal => { const execution = await load(query.source, signal), status = await execution.status(query, signal); signal.throwIfAborted(); return { ...status, jobs: [...jobs.values()].map(entry => structuredClone(entry.job)) } })
72
+ },
73
+ list() { available(); return [...jobs.values()].map(entry => structuredClone(entry.job)) },
74
+ get(query: VoiceoverJobQuery): VoiceoverObservation {
75
+ available()
76
+ if (!validateVoiceoverJobQuery(query)) throw new VoiceoverOperationError(400, 'voice-job-query-invalid')
77
+ const intent = find(query)
78
+ if (!intent) throw new VoiceoverOperationError(404, 'voice-job-unknown')
79
+ return observation('intent' in query ? query.intent : intent.job!.job.intent, intent)
80
+ },
81
+ cancel(query: VoiceoverJobQuery): VoiceoverCancellation {
82
+ available()
83
+ if (!validateVoiceoverJobQuery(query)) throw new VoiceoverOperationError(400, 'voice-cancel-invalid')
84
+ const intent = 'intent' in query ? intents.get(query.intent) ?? newIntent(query.intent) : find(query)
85
+ if (!intent) throw new VoiceoverOperationError(404, 'voice-job-unknown')
86
+ if (intent.job) cancelJob(intent.job)
87
+ else { intent.state = 'cancelled'; intent.controller.abort(new Error('voice-cancelled')); if (!intent.payload) intent.finish() }
88
+ return observation('intent' in query ? query.intent : intent.job!.job.intent, intent)
89
+ },
90
+ /** Cancellation that would change nothing (the job or intent already ended) is answered here, without asking. */
91
+ checkCancel(query: VoiceoverJobQuery): VoiceoverCancellation | undefined {
92
+ available()
93
+ if (!validateVoiceoverJobQuery(query)) throw new VoiceoverOperationError(400, 'voice-cancel-invalid')
94
+ const intent = find(query)
95
+ if (!intent) { if ('intent' in query) return undefined; throw new VoiceoverOperationError(404, 'voice-job-unknown') }
96
+ const state = intent.job?.job.state ?? intent.state
97
+ return state === 'queued' || state === 'running' || state === 'preparing' ? undefined : observation('intent' in query ? query.intent : intent.job!.job.intent, intent)
98
+ },
99
+ /** The same admission as start, released at once: a request that passes would be admitted unless the document changes. */
100
+ checkStart(request: VoiceoverStart, load: Load, signal?: AbortSignal): Promise<void> {
101
+ if (!validateVoiceoverStart(request)) throw new VoiceoverOperationError(400, 'voice-generation-invalid')
102
+ return work(signal, async signal => {
103
+ const known = intents.get(request.intent)
104
+ if (known?.payload && known.payload !== startPayload(request)) throw new VoiceoverOperationError(409, 'voice-intent-changed')
105
+ if (known?.job) return
106
+ if (known?.state === 'cancelled') throw new VoiceoverOperationError(409, 'voice-cancelled')
107
+ if (known?.state === 'failed') throw new VoiceoverOperationError(409, 'voice-intent-failed')
108
+ if (known) throw new VoiceoverOperationError(409, 'voice-job-busy')
109
+ const execution = await load(request.source, signal)
110
+ ;(await execution.prepare(structuredClone(request), signal)).dispose()
111
+ })
112
+ },
113
+ checkPreview(request: VoicePreviewStart, signal?: AbortSignal): Promise<void> {
114
+ if (!validateVoicePreviewStart(request)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
115
+ return work(signal, async signal => {
116
+ const quoted = quotes.get(request.quote)
117
+ if (!quoted || quoted.expires < Date.now() || previewPayload(quoted.value) !== previewPayload(request)) throw new VoiceoverOperationError(409, 'voice-preview-changed')
118
+ const { quote: _, ...check } = request
119
+ if ((await quoted.execution.quote(check, signal)).quote !== quoted.underlying) throw new VoiceoverOperationError(409, 'voice-preview-changed')
120
+ })
121
+ },
122
+ start(request: VoiceoverStart, load: Load, signal?: AbortSignal): Promise<VoiceoverJob> {
123
+ if (!validateVoiceoverStart(request)) throw new VoiceoverOperationError(400, 'voice-generation-invalid')
124
+ request = structuredClone(request)
125
+ return work(signal, async signal => {
126
+ const payload = startPayload(request), known = intents.get(request.intent)
127
+ if (known?.payload && known.payload !== payload) throw new VoiceoverOperationError(409, 'voice-intent-changed')
128
+ if (known?.job) return structuredClone(known.job.job)
129
+ if (known?.state === 'cancelled') throw new VoiceoverOperationError(409, 'voice-cancelled')
130
+ if (known?.state === 'failed') throw new VoiceoverOperationError(409, 'voice-intent-failed')
131
+ if (known) throw new VoiceoverOperationError(409, 'voice-job-busy')
132
+ const intent = newIntent(request.intent, payload), combined = AbortSignal.any([signal, intent.controller.signal])
133
+ let prepared: Awaited<ReturnType<VoiceoverExecution['prepare']>> | undefined, admitted = false
134
+ try {
135
+ combined.throwIfAborted()
136
+ const execution = await load(request.source, combined)
137
+ combined.throwIfAborted(); prepared = await execution.prepare(request, combined); combined.throwIfAborted(); available()
138
+ const job: VoiceoverJob = { id: randomUUID(), sequence: 1, intent: request.intent, source: prepared.source, revision: prepared.revision, scope: prepared.scope, state: 'queued', total: prepared.total, completed: 0, failed: 0, createdAt: new Date().toISOString() }
139
+ const entry = { job, controller: intent.controller }, task = prepared
140
+ intent.job = entry; jobs.set(job.id, entry); admitted = true
141
+ void Promise.resolve().then(async () => {
142
+ if (job.state !== 'queued') return
143
+ patch(job, { state: 'running', startedAt: new Date().toISOString() })
144
+ try { const result = await task.run(intent.controller.signal, progress => patch(job, progress)); patch(job, { ...result, finishedAt: new Date().toISOString() }) }
145
+ catch (error) { patch(job, { state: intent.controller.signal.aborted ? 'cancelled' : 'failed', error: error instanceof Error ? error.message : String(error), finishedAt: new Date().toISOString() }) }
146
+ }).finally(() => { task.dispose(); intent.finish() })
147
+ for (const [id, old] of jobs) if (jobs.size > RECENT_JOBS && !['queued', 'running', 'cancelling'].includes(old.job.state)) jobs.delete(id)
148
+ return structuredClone(job)
149
+ } catch (error) {
150
+ const intentCancelled = intent.controller.signal.aborted
151
+ intent.state = combined.aborted ? 'cancelled' : 'failed'; intent.error = error instanceof Error ? error.message : String(error)
152
+ intent.controller.abort(error)
153
+ if (closed) throw new VoiceoverOperationError(503, 'voice-service-closing')
154
+ if (intentCancelled) throw new VoiceoverOperationError(409, 'voice-cancelled')
155
+ throw error
156
+ } finally { if (!admitted) { prepared?.dispose(); intent.finish() } }
157
+ })
158
+ },
159
+ quote(query: VoicePreviewCheck, load: Load, signal?: AbortSignal): Promise<VoicePreviewQuote> {
160
+ if (!validateVoicePreviewCheck(query)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
161
+ return work(signal, async signal => {
162
+ const execution = await load(query.source, signal), quoted = await execution.quote(query, signal)
163
+ signal.throwIfAborted(); available()
164
+ const token = digest([randomUUID(), quoted.quote]), value = { ...quoted, quote: token }
165
+ quotes.set(token, { value, underlying: quoted.quote, execution, expires: Date.now() + QUOTE_MS })
166
+ while (quotes.size > MAX_QUOTES) quotes.delete(quotes.keys().next().value!)
167
+ return structuredClone(value)
168
+ })
169
+ },
170
+ preview(request: VoicePreviewStart, signal?: AbortSignal): Promise<VoicePreviewResource> {
171
+ if (!validateVoicePreviewStart(request)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
172
+ return work(signal, async signal => {
173
+ const quoted = quotes.get(request.quote)
174
+ if (!quoted || quoted.expires < Date.now() || previewPayload(quoted.value) !== previewPayload(request)) throw new VoiceoverOperationError(409, 'voice-preview-changed')
175
+ const value = await quoted.execution.preview({ ...request, quote: quoted.underlying }, signal)
176
+ signal.throwIfAborted(); available()
177
+ if (value.bytes.byteLength > 8 << 20) throw new VoiceoverOperationError(413, 'voice-preview-too-large')
178
+ const { bytes, ...answer } = value, resource: VoicePreviewResource = { ...answer, id: randomUUID(), byteLength: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') }
179
+ media.set(resource.id, { resource, bytes: new Uint8Array(bytes) })
180
+ while (media.size > MAX_PREVIEWS || [...media.values()].reduce((sum, value) => sum + value.bytes.byteLength, 0) > MAX_MEDIA_BYTES) media.delete(media.keys().next().value!)
181
+ return structuredClone(resource)
182
+ })
183
+ },
184
+ readPreview(id: string): VoicePreviewResourceData {
185
+ available()
186
+ const value = media.get(id)
187
+ if (!value) throw new VoiceoverOperationError(404, 'voice-preview-resource-unknown')
188
+ return { resource: structuredClone(value.resource), bytes: new Uint8Array(value.bytes) }
189
+ },
190
+ async close() {
191
+ closed = true
192
+ for (const operation of operations) operation.controller.abort(new Error('voice-service-closing'))
193
+ for (const intent of intents.values()) { if (intent.job) cancelJob(intent.job); else intent.controller.abort(new Error('voice-service-closing')) }
194
+ await Promise.allSettled([...operations].map(operation => operation.finished))
195
+ await Promise.allSettled([...intents.values()].map(intent => intent.finished))
196
+ quotes.clear(); media.clear()
197
+ },
198
+ }
199
+ }