@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.10

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 (74) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/presmium-lecture.mjs +12 -0
  4. package/package.json +68 -3
  5. package/src/client/ChapterList.tsx +50 -0
  6. package/src/client/LectureView.css +21 -0
  7. package/src/client/LectureView.tsx +84 -0
  8. package/src/client/PlayLectureButton.tsx +21 -0
  9. package/src/client/PlayerPanel.css +153 -0
  10. package/src/client/PlayerPanel.tsx +198 -0
  11. package/src/client/RecorderPanel.css +46 -0
  12. package/src/client/RecorderPanel.tsx +282 -0
  13. package/src/client/TranscriptList.tsx +28 -0
  14. package/src/client/clock.ts +179 -0
  15. package/src/client/env.d.ts +7 -0
  16. package/src/client/media.ts +86 -0
  17. package/src/client/playback.ts +140 -0
  18. package/src/client/player.ts +202 -0
  19. package/src/client/plugin.ts +33 -0
  20. package/src/client/pointer-ink.ts +143 -0
  21. package/src/client/production-playback.ts +188 -0
  22. package/src/client/recorder.ts +245 -0
  23. package/src/client/source.ts +109 -0
  24. package/src/client/state.ts +45 -0
  25. package/src/client/viewer.ts +37 -0
  26. package/src/domain/audience.ts +108 -0
  27. package/src/domain/caption-timing.ts +59 -0
  28. package/src/domain/captions.ts +59 -0
  29. package/src/domain/config.ts +67 -0
  30. package/src/domain/drift.ts +159 -0
  31. package/src/domain/editing.ts +112 -0
  32. package/src/domain/format.ts +13 -0
  33. package/src/domain/index.ts +50 -0
  34. package/src/domain/messages.ts +324 -0
  35. package/src/domain/narration.ts +160 -0
  36. package/src/domain/plan.ts +319 -0
  37. package/src/domain/production-audio.ts +39 -0
  38. package/src/domain/production.ts +136 -0
  39. package/src/domain/recording.ts +130 -0
  40. package/src/domain/script-track.ts +176 -0
  41. package/src/domain/timing.ts +107 -0
  42. package/src/domain/track.ts +302 -0
  43. package/src/domain/translation.ts +34 -0
  44. package/src/domain/vtt.ts +68 -0
  45. package/src/domain/words.ts +114 -0
  46. package/src/domain/writeback.ts +129 -0
  47. package/src/node/audio-authoring.ts +53 -0
  48. package/src/node/check.ts +93 -0
  49. package/src/node/cli.ts +82 -0
  50. package/src/node/clips.ts +10 -0
  51. package/src/node/engines.ts +43 -0
  52. package/src/node/export.ts +331 -0
  53. package/src/node/ffmpeg.ts +76 -0
  54. package/src/node/latest-ir.ts +54 -0
  55. package/src/node/plan.ts +35 -0
  56. package/src/node/presmium.ts +59 -0
  57. package/src/node/production-assets.ts +112 -0
  58. package/src/node/production.ts +102 -0
  59. package/src/node/renderer.ts +182 -0
  60. package/src/node/roles.ts +23 -0
  61. package/src/node/routes/production.ts +51 -0
  62. package/src/node/routes/recordings.ts +122 -0
  63. package/src/node/routes/serve-file.ts +38 -0
  64. package/src/node/routes/served-deck.ts +61 -0
  65. package/src/node/routes/server.ts +12 -0
  66. package/src/node/routes/tracks.ts +98 -0
  67. package/src/node/routes/voiceover.ts +23 -0
  68. package/src/node/store.ts +192 -0
  69. package/src/node/synthesize.ts +190 -0
  70. package/src/node/tools.ts +170 -0
  71. package/src/node/transcribe.ts +53 -0
  72. package/src/node/voice-generation.ts +15 -0
  73. package/src/node/voice-source.ts +34 -0
  74. package/src/node/voiceover-execution.ts +223 -0
@@ -0,0 +1,61 @@
1
+ import { DOCUMENT_FILES, SPEECH } from '@lincyaw/kernel'
2
+ import type { ServerContext, SpeechService } from '@lincyaw/kernel'
3
+ import { configuredLectureVoice } from '../../domain/index.ts'
4
+ import type { LectureTrack, LectureVoice } from '../../domain/index.ts'
5
+ import { latestDoc } from '../latest-ir.ts'
6
+ import { deckFiles, mediaPolicy, readTrack, trackDir } from '../store.ts'
7
+ import type { DeckFiles, MediaPolicy } from '../store.ts'
8
+
9
+ export class RouteRefusal extends Error {
10
+ readonly status: number
11
+
12
+ constructor(status: number, message: string) {
13
+ super(message)
14
+ this.status = status
15
+ }
16
+ }
17
+
18
+ export interface ServedDeck {
19
+ ctx: ServerContext
20
+ deckDir: string
21
+ files: DeckFiles
22
+ policy: MediaPolicy
23
+ speech(): SpeechService
24
+ configuredVoice(): LectureVoice | undefined
25
+ findStoredTrack(id: string): LectureTrack | undefined
26
+ getStoredTrack(id: string): LectureTrack
27
+ }
28
+
29
+ export function createServedDeck(ctx: ServerContext): ServedDeck {
30
+ const { deckDir } = ctx
31
+ const findStoredTrack = (id: string) => (trackDir(deckDir, id) ? readTrack(deckDir, id)?.track : undefined)
32
+ return {
33
+ ctx,
34
+ deckDir,
35
+ files: deckFiles(ctx.workspaceRoot, () => ctx.service(DOCUMENT_FILES)),
36
+ policy: mediaPolicy(ctx.settings),
37
+ speech() {
38
+ const found = ctx.service(SPEECH)
39
+ if (!found) throw new RouteRefusal(503, 'no speech service: add @lincyaw/addon-speech to the deck\'s plugins')
40
+ return found
41
+ },
42
+ configuredVoice() {
43
+ const doc = latestDoc(deckDir)
44
+ return doc ? configuredLectureVoice(doc) : undefined
45
+ },
46
+ findStoredTrack,
47
+ getStoredTrack(id) {
48
+ const track = findStoredTrack(id)
49
+ if (!track) throw new RouteRefusal(404, `no lecture ${id}`)
50
+ return track
51
+ },
52
+ }
53
+ }
54
+
55
+ export function decodedPath(segments: string[]): string {
56
+ try {
57
+ return segments.map(decodeURIComponent).join('/')
58
+ } catch {
59
+ return ''
60
+ }
61
+ }
@@ -0,0 +1,12 @@
1
+ import type { ServerContext, ServerContribution } from '@lincyaw/kernel'
2
+ import { productionRoutes } from './production.ts'
3
+ import { recordingRoutes } from './recordings.ts'
4
+ import { createServedDeck } from './served-deck.ts'
5
+ import { trackRoutes } from './tracks.ts'
6
+ import { voiceoverRoutes } from './voiceover.ts'
7
+
8
+ export function lectureServer(ctx: ServerContext): ServerContribution {
9
+ const served = createServedDeck(ctx)
10
+ const voiceover = voiceoverRoutes(served)
11
+ return { routes: { ...trackRoutes(served), ...productionRoutes(served), ...recordingRoutes(served), ...voiceover.routes }, close: voiceover.close }
12
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { irRevision } from '@lincyaw/kernel/node'
3
+ import { errorMessage, jsonResponse, problemResponse } from '@lincyaw/kernel'
4
+ import type { Route } from '@lincyaw/kernel'
5
+ import { CLIPS_DIR, SCRIPT_TRACK, trackDuration } from '../../domain/index.ts'
6
+ import type { LectureTrack, MediaSegment } from '../../domain/index.ts'
7
+ import { cachedClip } from '../clips.ts'
8
+ import { latestDoc } from '../latest-ir.ts'
9
+ import { mediaFile, publishedMedia, trackIds } from '../store.ts'
10
+ import { productionSnapshot } from '../production.ts'
11
+ import { deckPlan } from '../plan.ts'
12
+ import { serveFile } from './serve-file.ts'
13
+ import { decodedPath, RouteRefusal } from './served-deck.ts'
14
+ import type { ServedDeck } from './served-deck.ts'
15
+
16
+ const RECENT_CLIP_SETS = 8
17
+
18
+ export function trackRoutes(served: ServedDeck): Record<string, Route> {
19
+ const { deckDir } = served
20
+
21
+ const recentClipSets = new Map<string, Set<string>>()
22
+ const synthesizeScriptTrack = async (request: Request): Promise<LectureTrack> => {
23
+ const planId = new URL(request.url).searchParams.get('planId')
24
+ const doc = latestDoc(deckDir)
25
+ const expected = planId && doc?.source ? { planId, source: doc.source, irRevision: irRevision(doc) } : undefined
26
+ const snapshot = await productionSnapshot(served, expected, request.signal)
27
+ if (snapshot.plan.diagnostics.some(issue => issue.severity === 'error')) throw new RouteRefusal(409, 'production-plan-unavailable')
28
+ const names = snapshot.track.utterances.flatMap(utterance => utterance.clip ?? []), key = names.join('\n')
29
+ recentClipSets.delete(key); recentClipSets.set(key, new Set(names))
30
+ if (recentClipSets.size > RECENT_CLIP_SETS) recentClipSets.delete(recentClipSets.keys().next().value!)
31
+ return snapshot.track
32
+ }
33
+
34
+ const serveClip = async (id: string, name: string, request: Request): Promise<Response> => {
35
+ const said = id === SCRIPT_TRACK && served.configuredVoice()
36
+ ? [...recentClipSets.values()].some(names => names.has(name))
37
+ : served.findStoredTrack(id)?.utterances.some(u => u.clip === name)
38
+ if (!said) return problemResponse(404, `no clip ${name} in the lecture ${id}`)
39
+ let clip
40
+ try {
41
+ clip = await cachedClip(served.speech(), name)
42
+ } catch (error) {
43
+ if (error instanceof RouteRefusal) return problemResponse(error.status, error.message)
44
+ return problemResponse(403, `the clip ${name} may not be given: ${errorMessage(error)}`)
45
+ }
46
+ if (!clip) return problemResponse(404, `the clip ${name} is not in the synthesis cache`)
47
+ return serveFile(deckDir, clip.audio, request)
48
+ }
49
+
50
+ const withPublishedMedia = (track: LectureTrack): LectureTrack => {
51
+ const isLocal = (segment: MediaSegment) => {
52
+ const file = mediaFile(deckDir, track.id, segment.src)
53
+ return Boolean(file && existsSync(file))
54
+ }
55
+ const audio = publishedMedia(track.audio, track.id, served.policy, isLocal)
56
+ const video = publishedMedia(track.video, track.id, served.policy, isLocal)
57
+ return { ...track, ...audio ? { audio } : {}, ...video ? { video } : {} }
58
+ }
59
+
60
+ const serveTrackFile = (id: string, path: string[], request: Request): Promise<Response> | Response => {
61
+ if (path[0] === CLIPS_DIR && path.length === 2) return serveClip(id, decodedPath(path.slice(1)), request)
62
+ return serveFile(deckDir, mediaFile(deckDir, id, decodedPath(path)), request)
63
+ }
64
+
65
+ return {
66
+ 'tracks': {
67
+ role: 'reader',
68
+ handle() {
69
+ const doc = latestDoc(deckDir)
70
+ const voice = served.configuredVoice()
71
+ const silent = doc && deckPlan(doc).silent
72
+ const synthetic = (voice || silent) && doc && !deckPlan(doc).findings.some(finding => finding.severity === 'error') ? [{ id: SCRIPT_TRACK, lang: doc.meta.lang ?? 'zh-CN', voice: voice ? { kind: 'synthetic', ...voice } : { kind: 'silent' } }] : []
73
+ return jsonResponse([...synthetic, ...trackIds(deckDir).filter(id => !((voice || silent) && id === SCRIPT_TRACK)).flatMap((id) => {
74
+ try {
75
+ const track = served.findStoredTrack(id)
76
+ return track ? [{ id, lang: track.lang, voice: track.voice, duration: trackDuration(track), utterances: track.utterances.length }] : []
77
+ } catch {
78
+ return []
79
+ }
80
+ })])
81
+ },
82
+ },
83
+ 'tracks/*': {
84
+ role: 'reader',
85
+ async handle(request, { rest }) {
86
+ const [id = '', ...path] = rest.split('/')
87
+ try {
88
+ if (path.length > 0) return await serveTrackFile(id, path, request)
89
+ if (id === SCRIPT_TRACK && (served.configuredVoice() || latestDoc(deckDir) && deckPlan(latestDoc(deckDir)!).silent)) return jsonResponse(await synthesizeScriptTrack(request))
90
+ const track = served.findStoredTrack(id)
91
+ return track ? jsonResponse(withPublishedMedia(track)) : problemResponse(404, `no lecture ${id}`)
92
+ } catch (error) {
93
+ return problemResponse(error instanceof RouteRefusal ? error.status : 500, errorMessage(error))
94
+ }
95
+ },
96
+ },
97
+ }
98
+ }
@@ -0,0 +1,23 @@
1
+ import { DOCUMENT_VOICE_TASKS } from '@lincyaw/kernel'
2
+ import type { DocumentVoiceTasks, ServerContribution } from '@lincyaw/kernel'
3
+ import { createVoiceoverOwner, createVoiceoverRoutes } from '@lincyaw/kernel/node'
4
+ import { voiceoverExecution } from '../voiceover-execution.ts'
5
+ import type { ServedDeck } from './served-deck.ts'
6
+
7
+ /** Workspace contributions borrow its owner; standalone servers own the same coordinator factory. */
8
+ export function voiceoverRoutes(served: ServedDeck): ServerContribution {
9
+ const borrowed = served.ctx.service(DOCUMENT_VOICE_TASKS)
10
+ if (borrowed) return { routes: createVoiceoverRoutes(borrowed) }
11
+ const owner = createVoiceoverOwner(), execution = voiceoverExecution(served.ctx), load = async () => execution
12
+ const service: DocumentVoiceTasks = {
13
+ snapshot: (_document, query, _member, signal) => owner.snapshot(query, load, signal),
14
+ listJobs: async document => ({ document, jobs: owner.list().map(job => ({ ...job, current: 'unknown' as const })), idempotency: 'process', limits: owner.limits }),
15
+ getJob: async (_document, query) => ({ ...owner.get(query), current: 'unknown' }),
16
+ start: (_document, request, _member, signal) => owner.start(request, load, signal),
17
+ cancel: async (_document, query) => owner.cancel(query),
18
+ quotePreview: (_document, query, _member, signal) => owner.quote(query, load, signal),
19
+ preview: (_document, request, _member, signal) => owner.preview(request, signal),
20
+ readPreview: async (_document, id) => owner.readPreview(id),
21
+ }
22
+ return { routes: createVoiceoverRoutes(service), close: () => owner.close() }
23
+ }
@@ -0,0 +1,192 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { closeSync, constants, createReadStream, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { open } from 'node:fs/promises'
4
+ import { dirname, join, relative, sep } from 'node:path'
5
+ import { ABSOLUTE_URL } from '@lincyaw/kernel'
6
+ import type { DocumentFiles } from '@lincyaw/kernel'
7
+ import { isInside } from '@lincyaw/kernel/node'
8
+ import { formatTrack, LECTURES_DIR, parseTrack, TRACK_ID } from '../domain/index.ts'
9
+ import type { LectureTrack, MediaSegment } from '../domain/index.ts'
10
+
11
+ export interface DeckFiles {
12
+ write(file: string, content: string | Uint8Array, at?: number): Promise<void>
13
+ remove(file: string): Promise<void>
14
+ }
15
+
16
+ export function deckFiles(workspaceRoot: string, documentFiles: () => DocumentFiles | undefined): DeckFiles {
17
+ const path = (file: string) => relative(workspaceRoot, file).split(sep).join('/')
18
+ const bytes = (content: string | Uint8Array) => (typeof content === 'string' ? Buffer.from(content) : content)
19
+ return {
20
+ async write(file, content, at) {
21
+ const files = documentFiles()
22
+ if (files) return files.write([{ path: path(file), content: bytes(content), ...at !== undefined ? { at } : {} }])
23
+ mkdirSync(dirname(file), { recursive: true })
24
+ if (at === undefined) return writeFileSync(file, content)
25
+ const handle = await open(file, existsSync(file) ? 'r+' : 'w+')
26
+ try {
27
+ const data = bytes(content)
28
+ await handle.write(data, 0, data.byteLength, at)
29
+ } finally {
30
+ await handle.close()
31
+ }
32
+ },
33
+ async remove(file) {
34
+ const files = documentFiles()
35
+ if (files) return files.write([{ path: path(file), remove: true }])
36
+ rmSync(file, { force: true })
37
+ },
38
+ }
39
+ }
40
+
41
+ export const TRACK_FILE = 'track.json'
42
+
43
+ const MEDIA_PATH = /^(audio|video)\/[^/\\]+\.(ogg|opus|webm|mp4|m4a|wav|mp3)$/
44
+
45
+ export function lecturesDir(deckDir: string): string {
46
+ return join(deckDir, LECTURES_DIR)
47
+ }
48
+
49
+ export function trackDir(deckDir: string, id: string): string | undefined {
50
+ return TRACK_ID.test(id) ? join(lecturesDir(deckDir), id) : undefined
51
+ }
52
+
53
+ export function mediaFile(deckDir: string, id: string, path: string): string | undefined {
54
+ const dir = trackDir(deckDir, id)
55
+ if (!dir || !MEDIA_PATH.test(path) || path.split('/').some(part => part === '..' || part.startsWith('.'))) return undefined
56
+ const file = join(dir, path)
57
+ return relative(dir, file).split(sep)[0] === path.split('/')[0] ? file : undefined
58
+ }
59
+
60
+ export interface StoredTrack {
61
+ track: LectureTrack
62
+ text: string
63
+ file: string
64
+ problems: string[]
65
+ }
66
+
67
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
68
+
69
+ function openedPath(fd: number, expected: string): string | undefined {
70
+ try {
71
+ return readlinkSync(`/proc/self/fd/${fd}`)
72
+ } catch {
73
+ try {
74
+ const opened = fstatSync(fd)
75
+ const now = statSync(expected)
76
+ return realpathSync(expected) === expected && now.dev === opened.dev && now.ino === opened.ino ? expected : undefined
77
+ } catch {
78
+ return undefined
79
+ }
80
+ }
81
+ }
82
+
83
+ export function openInDeck(deckDir: string, file: string): number | undefined {
84
+ let expected: string
85
+ try {
86
+ const rel = relative(deckDir, file)
87
+ if (!rel || !isInside(file, deckDir) || rel.split(sep).includes('.git')) return undefined
88
+ expected = join(realpathSync(deckDir), rel)
89
+ } catch {
90
+ return undefined
91
+ }
92
+ let fd: number
93
+ try {
94
+ fd = openSync(file, OPEN_FLAGS)
95
+ } catch {
96
+ return undefined
97
+ }
98
+ try {
99
+ if (fstatSync(fd).isFile() && openedPath(fd, expected) === expected) return fd
100
+ } catch {
101
+ }
102
+ closeSync(fd)
103
+ return undefined
104
+ }
105
+
106
+ export function readInDeck(deckDir: string, file: string): Buffer | undefined {
107
+ const fd = openInDeck(deckDir, file)
108
+ if (fd === undefined) return undefined
109
+ try {
110
+ return readFileSync(fd)
111
+ } catch {
112
+ return undefined
113
+ } finally {
114
+ closeSync(fd)
115
+ }
116
+ }
117
+
118
+ export function readTrack(deckDir: string, id: string): StoredTrack | undefined {
119
+ const dir = trackDir(deckDir, id)
120
+ const file = dir && join(dir, TRACK_FILE)
121
+ const bytes = file ? readInDeck(deckDir, file) : undefined
122
+ if (!file || !bytes) return undefined
123
+ const text = bytes.toString('utf8')
124
+ const { track, problems } = parseTrack(JSON.parse(text))
125
+ if (!track) throw new Error(`${relative(deckDir, file)}: ${problems.join('; ')}`)
126
+ return { track, text, file, problems }
127
+ }
128
+
129
+ export function trackIds(deckDir: string): string[] {
130
+ const dir = lecturesDir(deckDir)
131
+ if (!existsSync(dir)) return []
132
+ return readdirSync(dir, { withFileTypes: true })
133
+ .filter(entry => entry.isDirectory() && TRACK_ID.test(entry.name) && existsSync(join(dir, entry.name, TRACK_FILE)))
134
+ .map(entry => entry.name)
135
+ .sort()
136
+ }
137
+
138
+ export async function writeTrack(files: DeckFiles, deckDir: string, track: LectureTrack): Promise<string> {
139
+ const dir = trackDir(deckDir, track.id)
140
+ if (!dir) throw new Error(`invalid lecture id ${track.id}`)
141
+ const file = join(dir, TRACK_FILE)
142
+ await files.write(file, formatTrack(track))
143
+ return file
144
+ }
145
+
146
+ export function newTrackId(deckDir: string, now = new Date()): string {
147
+ const day = now.toISOString().slice(0, 10)
148
+ const taken = new Set(existsSync(lecturesDir(deckDir)) ? readdirSync(lecturesDir(deckDir)) : [])
149
+ if (!taken.has(day)) return day
150
+ let n = 2
151
+ while (taken.has(`${day}-${n}`)) n++
152
+ return `${day}-${n}`
153
+ }
154
+
155
+ export function freeMediaPath(deckDir: string, id: string, folder: 'audio' | 'video', stem: string, ext: string): string {
156
+ const dir = trackDir(deckDir, id)!
157
+ let path = `${folder}/${stem}.${ext}`
158
+ for (let n = 2; existsSync(join(dir, path)); n++) path = `${folder}/${stem}-${n}.${ext}`
159
+ return path
160
+ }
161
+
162
+ export async function sha256File(deckDir: string, file: string): Promise<string | undefined> {
163
+ const fd = openInDeck(deckDir, file)
164
+ if (fd === undefined) return undefined
165
+ const hash = createHash('sha256')
166
+ for await (const chunk of createReadStream('', { fd })) hash.update(chunk as Buffer)
167
+ return hash.digest('hex')
168
+ }
169
+
170
+ export type MediaPolicy = { kind: 'ignore' } | { kind: 'lfs' } | { kind: 'url', prefix: string }
171
+
172
+ export function mediaPolicy(settings: Record<string, unknown>): MediaPolicy {
173
+ const audio = settings.audio
174
+ if (audio === 'lfs') return { kind: 'lfs' }
175
+ if (typeof audio === 'string' && /^https?:\/\//.test(audio)) return { kind: 'url', prefix: audio.endsWith('/') ? audio : `${audio}/` }
176
+ return { kind: 'ignore' }
177
+ }
178
+
179
+ export function publishedMedia<T extends MediaSegment>(segments: T[] | undefined, id: string, policy: MediaPolicy, local: (segment: T) => boolean = () => false): T[] | undefined {
180
+ if (!segments || policy.kind !== 'url') return segments
181
+ return segments.map(segment => (ABSOLUTE_URL.test(segment.src) || local(segment) ? segment : { ...segment, src: `${policy.prefix}${id}/${segment.src}` }))
182
+ }
183
+
184
+ export async function writeGitFiles(files: DeckFiles, dir: string, policy: MediaPolicy): Promise<void> {
185
+ if (policy.kind === 'lfs') {
186
+ await files.write(join(dir, '.gitattributes'), 'audio/** filter=lfs diff=lfs merge=lfs -text\nvideo/** filter=lfs diff=lfs merge=lfs -text\n')
187
+ await files.remove(join(dir, '.gitignore'))
188
+ return
189
+ }
190
+ const why = policy.kind === 'url' ? `published under ${policy.prefix}` : 'kept on this machine'
191
+ await files.write(join(dir, '.gitignore'), `# Recorded audio and video are ${why} (settings.presmium.lecture.audio).\naudio/\nvideo/\n`)
192
+ }
@@ -0,0 +1,190 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createReadStream } from 'node:fs'
3
+ import { stat } from 'node:fs/promises'
4
+ import { basename, relative } from 'node:path'
5
+ import { errorMessage, recordingSourceRange, remapWords } from '@lincyaw/kernel'
6
+ import type { IRDocument, ProductionDiagnostic, SpeechService, SynthesisRequest, WordSpan, WrittenWord } from '@lincyaw/kernel'
7
+ import { irHash, plannedUtterances, referencedBlocks, SCRIPT_TRACK, scriptTrack, sectionHashes } from '../domain/index.ts'
8
+ import type { Clip, CutAnimation, LecturePlan, LectureTrack, LectureVoice, RecordingClip, Voice } from '../domain/index.ts'
9
+ import type { DeckPlan } from './plan.ts'
10
+ import { deckPlan } from './plan.ts'
11
+ import { applyCaptionTimings } from '../domain/caption-timing.ts'
12
+ import { projectProductionPlan } from '../domain/production.ts'
13
+ import { placeProductionAudio, productionAudioMessage } from '../domain/production-audio.ts'
14
+ import { inspectProductionAssets } from './production-assets.ts'
15
+ import type { ProductionAssetFile } from './production-assets.ts'
16
+
17
+ export interface SynthesisOptions {
18
+ voice?: LectureVoice
19
+ lang: string
20
+ lexicon?: Record<string, string>
21
+ alignByTranscription?: boolean
22
+ cacheOnly?: boolean
23
+ /** Author generation verifies the canonical source; renderers omit this and remain IR/cache-only. */
24
+ assertCurrent?: () => Promise<void>
25
+ }
26
+
27
+ const audioStamp = async (path: string) => { const s = await stat(path); return [s.dev, s.ino, s.size, s.mtimeMs, s.ctimeMs].join(':') }
28
+
29
+ export interface SynthesizedClip extends Clip {
30
+ stamp: string
31
+ path: string
32
+ key: string
33
+ engine: string
34
+ model: string
35
+ voice: string
36
+ provider: string
37
+ }
38
+
39
+ export async function synthesizeClip(speech: SpeechService, said: { text: string, spoken: string, spans: WordSpan[] }, options: SynthesisOptions, signal?: AbortSignal): Promise<SynthesizedClip> {
40
+ const { voice } = options
41
+ if (!voice) throw new Error('voice-unset')
42
+ const request: SynthesisRequest = {
43
+ ...voice.engine ? { engine: voice.engine } : {},
44
+ voice: voice.voice,
45
+ text: said.spoken,
46
+ lang: options.lang,
47
+ ...voice.rate !== undefined ? { rate: voice.rate } : {},
48
+ ...voice.style ? { style: voice.style } : {},
49
+ ...options.lexicon ? { lexicon: options.lexicon } : {},
50
+ }
51
+ const synthesis = options.cacheOnly ? await speech.lookup?.(request, signal) : await speech.synthesize(request, signal)
52
+ if (!synthesis) throw new Error('voiceover-not-generated')
53
+ if (typeof synthesis.audio !== 'string') throw new Error('voiceover-cache-missing')
54
+ const engineWords = synthesis.words?.length
55
+ ? synthesis.words
56
+ : await speech.alignWords?.(synthesis.key, { lang: options.lang, transcribe: !options.cacheOnly && options.alignByTranscription !== false }, signal)
57
+ const words: WrittenWord[] | undefined = engineWords && remapWords(engineWords, said.text, said.spoken, said.spans)
58
+ const stamp = await audioStamp(synthesis.audio), digest = createHash('sha256')
59
+ for await (const chunk of createReadStream(synthesis.audio, { signal })) digest.update(chunk)
60
+ if (await audioStamp(synthesis.audio) !== stamp) throw new Error('production-media-changed')
61
+ return {
62
+ stamp, sha256: digest.digest('hex'),
63
+ file: basename(synthesis.audio),
64
+ path: synthesis.audio,
65
+ duration: synthesis.duration,
66
+ ...words ? { words } : {},
67
+ key: synthesis.key,
68
+ engine: synthesis.engine,
69
+ model: synthesis.model,
70
+ voice: synthesis.voice,
71
+ provider: synthesis.provider,
72
+ speech: { kind: 'synthetic', engine: synthesis.engine, model: synthesis.model, voice: synthesis.voice, provider: synthesis.provider },
73
+ }
74
+ }
75
+
76
+ export function clipVoice(clips: Iterable<SynthesizedClip>, fallback: LectureVoice | undefined): Voice {
77
+ const first = clips[Symbol.iterator]().next().value as SynthesizedClip | undefined
78
+ if (!first && !fallback) return { kind: 'silent' }
79
+ return {
80
+ kind: 'synthetic',
81
+ engine: first?.engine ?? fallback?.engine ?? '',
82
+ ...first?.model ? { model: first.model } : {},
83
+ voice: first?.voice ?? fallback?.voice ?? '',
84
+ ...first?.provider ? { provider: first.provider } : {},
85
+ }
86
+ }
87
+
88
+ export interface SynthesisFailure {
89
+ id: string
90
+ section: string
91
+ text: string
92
+ source?: { file: string, line: number }
93
+ reason: string
94
+ }
95
+
96
+ async function each<T>(items: readonly T[], work: (item: T) => Promise<void>, limit = 4): Promise<void> {
97
+ let next = 0
98
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
99
+ while (next < items.length) await work(items[next++]!)
100
+ }))
101
+ }
102
+
103
+ export async function synthesizePlan(speech: SpeechService, plan: LecturePlan, options: SynthesisOptions, signal?: AbortSignal): Promise<{ clips: Map<string, SynthesizedClip>, failures: SynthesisFailure[] }> {
104
+ const clips = new Map<string, SynthesizedClip>()
105
+ const failures: SynthesisFailure[] = []
106
+ await each(plannedUtterances(plan), async (u) => {
107
+ if (u.sourceKind === 'none' || u.sourceKind === 'recording') return
108
+ await options.assertCurrent?.()
109
+ if (options.assertCurrent) signal?.throwIfAborted()
110
+ try {
111
+ signal?.throwIfAborted()
112
+ if (u.voiceIssue) throw new Error(u.voiceIssue)
113
+ const clip = await synthesizeClip(speech, u, { ...options, voice: u.voice ?? options.voice }, signal)
114
+ await options.assertCurrent?.()
115
+ if (options.assertCurrent) signal?.throwIfAborted()
116
+ clips.set(u.id, clip)
117
+ } catch (error) {
118
+ await options.assertCurrent?.()
119
+ if (options.assertCurrent) signal?.throwIfAborted()
120
+ failures.push({ id: u.id, section: u.section, text: u.text, ...u.source ? { source: { file: u.source.file, line: u.source.line } } : {}, reason: errorMessage(error) })
121
+ }
122
+ }, options.assertCurrent ? 1 : 4)
123
+ await options.assertCurrent?.()
124
+ if (options.assertCurrent) signal?.throwIfAborted()
125
+ failures.sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)))
126
+ return { clips, failures }
127
+ }
128
+
129
+ export interface SynthesizedLecture {
130
+ captionDiagnostics?: ProductionDiagnostic[]
131
+ deck: DeckPlan
132
+ /** The lecture, every sentence said; none when one could not be. */
133
+ track?: LectureTrack
134
+ /** The lecture to play while some sentences could not be said: those are silent (`Utterance.unsaid`). */
135
+ preview?: LectureTrack
136
+ clips: Map<string, SynthesizedClip>
137
+ failures: SynthesisFailure[]
138
+ /** Where `{cue}next` moves on before an animation has played. */
139
+ cuts: CutAnimation[]
140
+ assets?: Map<string, ProductionAssetFile>
141
+ mediaDiagnostics?: ProductionDiagnostic[]
142
+ }
143
+
144
+ export async function synthesizeLecture(doc: IRDocument, deckDir: string, speech: SpeechService | undefined, signal?: AbortSignal, options: Pick<SynthesisOptions, 'cacheOnly' | 'assertCurrent'> = {}): Promise<SynthesizedLecture> {
145
+ const deck = deckPlan(doc)
146
+ const clips = new Map<string, SynthesizedClip>()
147
+ if (!options.cacheOnly && deck.findings.some(finding => finding.severity === 'error')) return { deck, clips, failures: [], cuts: [] }
148
+ const units = plannedUtterances(deck.plan)
149
+ const synthesized = speech ? await synthesizePlan(speech, deck.plan, { voice: deck.voice, lang: deck.lang, ...deck.lexicon ? { lexicon: deck.lexicon } : {}, cacheOnly: options.cacheOnly, assertCurrent: options.assertCurrent }, signal) : { clips, failures: units.filter(unit => unit.sourceKind !== 'none' && unit.sourceKind !== 'recording').map(unit => ({ id: unit.id, section: unit.section, text: unit.text, reason: unit.voiceIssue ?? 'speech-unavailable' })) }
150
+ const assets = await inspectProductionAssets(doc, deckDir, signal)
151
+ const recordings = new Map<string, RecordingClip>(), recordingDiagnostics: ProductionDiagnostic[] = []
152
+ for (const section of deck.plan.sections) for (const recording of section.recordings ?? []) {
153
+ const file = assets.assets.get(recording.assetId)
154
+ if (!file) {
155
+ const code = assets.errors.get(recording.assetId) ?? 'production.asset-missing'
156
+ recordingDiagnostics.push({ code, severity: 'error', message: productionAudioMessage(doc, code), sectionId: section.id, stepId: recording.stepId, assetId: recording.assetId })
157
+ } else if (!recording.issue) {
158
+ const range = recordingSourceRange({ type: 'recording', assetId: recording.assetId, sourceRange: recording.sourceRange }, file.asset.durationMs)
159
+ if (!range) recordingDiagnostics.push({ code: 'production.media-range', severity: 'error', message: productionAudioMessage(doc, 'production.media-range'), sectionId: section.id, stepId: recording.stepId, assetId: recording.assetId })
160
+ else recordings.set(recording.id, { assetId: recording.assetId, clipId: file.asset.mediaId, src: `audio/${file.asset.mediaId}.${file.extension}`, sha256: file.asset.sha256, durationMs: range.endMs - range.startMs, sourceRange: range })
161
+ }
162
+ }
163
+ const unsaid = new Map(synthesized.failures.map(failure => [failure.id, failure.reason]))
164
+ const { track, cuts } = scriptTrack(deck.plan, synthesized.clips, {
165
+ id: SCRIPT_TRACK,
166
+ doc: relative(deckDir, doc.meta.entry) || doc.meta.entry,
167
+ lang: deck.lang,
168
+ ir: { hash: irHash(doc), sections: sectionHashes(doc) },
169
+ voice: recordings.size && !synthesized.clips.size ? { kind: 'recorded' } : units.every(unit => unit.sourceKind === 'none') ? { kind: 'silent' } : clipVoice(units.flatMap(unit => synthesized.clips.get(unit.id) ?? []), deck.voice),
170
+ gap: deck.gap,
171
+ }, events => referencedBlocks(doc, events), unsaid, new Set(units.filter(unit => unit.sourceKind === 'none').map(unit => unit.id)), recordings)
172
+ const base = projectProductionPlan(doc, deck.plan, track, new Map(), synthesized.failures, recordingDiagnostics)
173
+ const media = placeProductionAudio(doc, base.pages, new Map([...assets.assets].map(([id, file]) => [id, file.asset])), assets.errors)
174
+ const captionDiagnostics = applyCaptionTimings(doc, track, base.pages)
175
+ const mediaDiagnostics: ProductionDiagnostic[] = [...recordingDiagnostics, ...media.issues.map(issue => ({ ...issue, severity: 'error' as const, message: productionAudioMessage(doc, issue.code) }))]
176
+ if (media.clips.length) track.audio = [...track.audio ?? [], ...media.clips.map(clip => {
177
+ const file = assets.assets.get(clip.assetId)!
178
+ return { section: clip.sectionId, start: clip.startMs / 1000, duration: (clip.endMs - clip.startMs) / 1000, src: `audio/${clip.mediaId}.${file.extension}`, clipId: clip.mediaId, assetId: clip.assetId, mediaClipId: clip.id, sourceStartMs: clip.sourceRange.startMs, volume: clip.volume, muted: clip.muted, sha256: file.asset.sha256 }
179
+ })]
180
+ await options.assertCurrent?.()
181
+ signal?.throwIfAborted()
182
+ const common = { deck, captionDiagnostics, preview: track, clips: synthesized.clips, failures: synthesized.failures, assets: assets.assets, mediaDiagnostics }
183
+ if (synthesized.failures.length > 0 || mediaDiagnostics.length || deck.findings.some(finding => finding.severity === 'error')) return { ...common, cuts: [] }
184
+ return { ...common, track, cuts }
185
+ }
186
+
187
+ export function synthesisFailureMessage(failure: SynthesisFailure, cacheOnly = false): string {
188
+ const text = failure.text.length > 40 ? `${failure.text.slice(0, 40)}…` : failure.text
189
+ return `lecture ${SCRIPT_TRACK}: 「${text}」 (section ${failure.section}) ${cacheOnly ? 'has no usable voiceover in the synthesis cache' : 'could not be said from the synthesis cache nor synthesized'}: ${failure.reason}`
190
+ }