@lincyaw/addon-lecture 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/presmium-lecture.mjs +12 -0
- package/package.json +68 -3
- package/src/client/ChapterList.tsx +50 -0
- package/src/client/LectureView.css +21 -0
- package/src/client/LectureView.tsx +84 -0
- package/src/client/PlayLectureButton.tsx +21 -0
- package/src/client/PlayerPanel.css +120 -0
- package/src/client/PlayerPanel.tsx +194 -0
- package/src/client/RecorderPanel.css +46 -0
- package/src/client/RecorderPanel.tsx +282 -0
- package/src/client/TranscriptList.tsx +28 -0
- package/src/client/clock.ts +169 -0
- package/src/client/env.d.ts +7 -0
- package/src/client/media.ts +86 -0
- package/src/client/playback.ts +140 -0
- package/src/client/player.ts +202 -0
- package/src/client/plugin.ts +33 -0
- package/src/client/pointer-ink.ts +143 -0
- package/src/client/production-playback.ts +188 -0
- package/src/client/recorder.ts +245 -0
- package/src/client/source.ts +109 -0
- package/src/client/state.ts +45 -0
- package/src/client/viewer.ts +37 -0
- package/src/domain/audience.ts +108 -0
- package/src/domain/caption-timing.ts +59 -0
- package/src/domain/captions.ts +59 -0
- package/src/domain/config.ts +67 -0
- package/src/domain/drift.ts +159 -0
- package/src/domain/editing.ts +112 -0
- package/src/domain/format.ts +13 -0
- package/src/domain/index.ts +50 -0
- package/src/domain/messages.ts +324 -0
- package/src/domain/narration.ts +160 -0
- package/src/domain/plan.ts +319 -0
- package/src/domain/production-audio.ts +39 -0
- package/src/domain/production.ts +136 -0
- package/src/domain/recording.ts +130 -0
- package/src/domain/script-track.ts +176 -0
- package/src/domain/timing.ts +107 -0
- package/src/domain/track.ts +302 -0
- package/src/domain/translation.ts +34 -0
- package/src/domain/vtt.ts +68 -0
- package/src/domain/words.ts +114 -0
- package/src/domain/writeback.ts +129 -0
- package/src/node/audio-authoring.ts +53 -0
- package/src/node/check.ts +93 -0
- package/src/node/cli.ts +82 -0
- package/src/node/clips.ts +10 -0
- package/src/node/engines.ts +43 -0
- package/src/node/export.ts +331 -0
- package/src/node/ffmpeg.ts +76 -0
- package/src/node/latest-ir.ts +54 -0
- package/src/node/plan.ts +35 -0
- package/src/node/presmium.ts +59 -0
- package/src/node/production-assets.ts +112 -0
- package/src/node/production.ts +102 -0
- package/src/node/renderer.ts +182 -0
- package/src/node/roles.ts +23 -0
- package/src/node/routes/production.ts +51 -0
- package/src/node/routes/recordings.ts +122 -0
- package/src/node/routes/serve-file.ts +38 -0
- package/src/node/routes/served-deck.ts +61 -0
- package/src/node/routes/server.ts +12 -0
- package/src/node/routes/tracks.ts +98 -0
- package/src/node/routes/voiceover.ts +23 -0
- package/src/node/store.ts +192 -0
- package/src/node/synthesize.ts +190 -0
- package/src/node/tools.ts +170 -0
- package/src/node/transcribe.ts +53 -0
- package/src/node/voice-generation.ts +15 -0
- package/src/node/voice-source.ts +34 -0
- 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
|
+
}
|