@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.1
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,331 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { open } from 'node:fs/promises'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { build, Compiler, playwrightChromium, serveDirectory } from '@lincyaw/compiler'
|
|
6
|
+
import { ABSOLUTE_URL, aigcLabel, aigcString, deckLocale, errorMessage } from '@lincyaw/kernel'
|
|
7
|
+
import type { CanonicalSource } from '@lincyaw/kernel'
|
|
8
|
+
import { ffmpegInstallCommand, findFfmpeg } from '@lincyaw/kernel/node'
|
|
9
|
+
import { audioSegments, clockText, defaultTrack, LECTURE_MESSAGES, parseTrack, trackDuration } from '../domain/index.ts'
|
|
10
|
+
import type { LectureTrack } from '../domain/index.ts'
|
|
11
|
+
import { ffmpegEncodes, startFfmpeg, videoFormat } from './ffmpeg.ts'
|
|
12
|
+
import type { FfmpegEncoder, VideoFormat } from './ffmpeg.ts'
|
|
13
|
+
|
|
14
|
+
export interface ExportOptions {
|
|
15
|
+
deckDir: string
|
|
16
|
+
/** Refuse to render if the fresh compilation differs from the requested canonical document. */
|
|
17
|
+
source?: CanonicalSource
|
|
18
|
+
track?: string
|
|
19
|
+
/** `.mp4` (H.264, AAC; needs FFmpeg) or `.webm`; MP4 when FFmpeg is there, else WebM. */
|
|
20
|
+
out?: string
|
|
21
|
+
width?: number
|
|
22
|
+
height?: number
|
|
23
|
+
fps?: number
|
|
24
|
+
/** Draws the captions in this language on the frames (the soft subtitle tracks are always there). */
|
|
25
|
+
burn?: string
|
|
26
|
+
log?: (line: string) => void
|
|
27
|
+
/** How many of the video's frames are drawn. */
|
|
28
|
+
progress?: (done: number, total: number) => void
|
|
29
|
+
/** Stops the export between two frames; it then rejects with the signal's reason. */
|
|
30
|
+
signal?: AbortSignal
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ExportResult {
|
|
34
|
+
file: string
|
|
35
|
+
track: string
|
|
36
|
+
frames: number
|
|
37
|
+
duration: number
|
|
38
|
+
/** The captions written next to the video, a WebVTT file a language. */
|
|
39
|
+
captions: { lang: string, file: string }[]
|
|
40
|
+
warnings: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ENCODER = `<!doctype html><meta charset="utf-8"><title>presmium-lecture export</title>
|
|
44
|
+
<script type="module">
|
|
45
|
+
import { AudioBufferSource, CanvasSource, canEncodeAudio, getFirstEncodableVideoCodec, Output, QUALITY_HIGH, StreamTarget, TextSubtitleSource, WebMOutputFormat } from './mediabunny.mjs'
|
|
46
|
+
const RATE = 48000
|
|
47
|
+
let output, video, audio, canvas, context, fps, total, done = 0, clips = [], subtitled = []
|
|
48
|
+
const base64 = (bytes) => {
|
|
49
|
+
let text = ''
|
|
50
|
+
for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
|
51
|
+
return btoa(text)
|
|
52
|
+
}
|
|
53
|
+
const bytes = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0))
|
|
54
|
+
async function decode(url) {
|
|
55
|
+
const response = await fetch(url)
|
|
56
|
+
if (!response.ok) throw new Error(url + ': HTTP ' + response.status)
|
|
57
|
+
const buffer = await new OfflineAudioContext(1, 1, RATE).decodeAudioData(await response.arrayBuffer())
|
|
58
|
+
if (buffer.numberOfChannels > 2) throw new Error('the media has more than two audio channels')
|
|
59
|
+
return Array.from({ length: buffer.numberOfChannels }, (_, channel) => buffer.getChannelData(channel))
|
|
60
|
+
}
|
|
61
|
+
/** The lecture's audio from ms a to ms b. */
|
|
62
|
+
function mix(a, b) {
|
|
63
|
+
const length = Math.max(1, Math.round((b - a) * RATE / 1000))
|
|
64
|
+
const buffer = new AudioBuffer({ length, numberOfChannels: 2, sampleRate: RATE })
|
|
65
|
+
for (const clip of clips) {
|
|
66
|
+
if (clip.start >= b || clip.end <= a || clip.muted || clip.volume === 0) continue
|
|
67
|
+
const first = Math.round(clip.sourceStartMs * RATE / 1000)
|
|
68
|
+
const from = Math.round((a - clip.start + clip.sourceStartMs) * RATE / 1000)
|
|
69
|
+
const last = Math.min(clip.samples[0].length, first + Math.round((clip.end - clip.start) * RATE / 1000))
|
|
70
|
+
for (let channel = 0; channel < 2; channel++) {
|
|
71
|
+
const data = buffer.getChannelData(channel), samples = clip.samples[channel] ?? clip.samples[0]
|
|
72
|
+
for (let i = Math.max(0, first - from); i < length && from + i < last; i++) data[i] += samples[from + i] * clip.volume
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (let channel = 0; channel < 2; channel++) { const data = buffer.getChannelData(channel); for (let i = 0; i < data.length; i++) data[i] = Math.max(-1, Math.min(1, data[i])) }
|
|
76
|
+
return buffer
|
|
77
|
+
}
|
|
78
|
+
async function load(list) {
|
|
79
|
+
const decoded = new Map()
|
|
80
|
+
for (const a of list) if (!decoded.has(a.url)) decoded.set(a.url, await decode(a.url))
|
|
81
|
+
clips = list.map(a => ({ start: a.start, end: a.start + a.duration, sourceStartMs: a.sourceStartMs ?? 0, volume: a.volume ?? 1, muted: a.muted ?? false, samples: decoded.get(a.url) }))
|
|
82
|
+
}
|
|
83
|
+
async function audioUntil(ms) {
|
|
84
|
+
while (audio && done < Math.min(ms, total)) {
|
|
85
|
+
const next = Math.min(done + 1000, total)
|
|
86
|
+
await audio.add(mix(done, next))
|
|
87
|
+
done = next
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
window.presmiumEncoder = {
|
|
91
|
+
async start(options) {
|
|
92
|
+
fps = options.fps
|
|
93
|
+
total = options.duration
|
|
94
|
+
canvas = new OffscreenCanvas(options.width, options.height)
|
|
95
|
+
context = canvas.getContext('2d')
|
|
96
|
+
const writable = new WritableStream({ write: chunk => window.presmiumWrite(chunk.position, base64(chunk.data)) })
|
|
97
|
+
output = new Output({ format: new WebMOutputFormat(), target: new StreamTarget(writable, { chunked: true, chunkSize: 4 * 1024 * 1024 }) })
|
|
98
|
+
const codec = await getFirstEncodableVideoCodec(['vp9', 'vp8'], { width: options.width, height: options.height })
|
|
99
|
+
if (!codec) throw new Error('this browser encodes neither VP9 nor VP8 video')
|
|
100
|
+
// Slides are text and thin lines: the bitrate is set for that, not for camera footage (QUALITY_HIGH gives 720p VP9 about 0.6 Mbit/s).
|
|
101
|
+
const bitrate = Math.round(options.width * options.height * fps * 0.12)
|
|
102
|
+
video = new CanvasSource(canvas, { codec, bitrate, keyFrameInterval: 10, latencyMode: 'quality', contentHint: 'text' })
|
|
103
|
+
output.addVideoTrack(video, { frameRate: fps })
|
|
104
|
+
if (options.audio.length > 0) {
|
|
105
|
+
if (!(await canEncodeAudio('opus'))) throw new Error('this browser encodes no Opus audio')
|
|
106
|
+
audio = new AudioBufferSource({ codec: 'opus', bitrate: QUALITY_HIGH })
|
|
107
|
+
output.addAudioTrack(audio)
|
|
108
|
+
await load(options.audio)
|
|
109
|
+
}
|
|
110
|
+
const subtitles = options.subtitles.map(({ language, name, vtt }) => {
|
|
111
|
+
const source = new TextSubtitleSource('webvtt')
|
|
112
|
+
output.addSubtitleTrack(source, { ...language ? { languageCode: language } : {}, name })
|
|
113
|
+
return { source, vtt }
|
|
114
|
+
})
|
|
115
|
+
if (options.metadata) output.setMetadataTags(options.metadata)
|
|
116
|
+
await output.start()
|
|
117
|
+
// The muxer interleaves the cues with the frames as they come, so the cues are not awaited here.
|
|
118
|
+
subtitled = subtitles.map(({ source, vtt }) => source.add(vtt))
|
|
119
|
+
},
|
|
120
|
+
async frame(png, index) {
|
|
121
|
+
const bitmap = await createImageBitmap(new Blob([bytes(png)], { type: 'image/png' }))
|
|
122
|
+
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
123
|
+
bitmap.close()
|
|
124
|
+
await video.add(index / fps, 1 / fps)
|
|
125
|
+
await audioUntil((index + 1) * 1000 / fps)
|
|
126
|
+
},
|
|
127
|
+
async finish() {
|
|
128
|
+
await audioUntil(total)
|
|
129
|
+
await Promise.all(subtitled)
|
|
130
|
+
await output.finalize()
|
|
131
|
+
},
|
|
132
|
+
/** The whole mix as 16-bit stereo PCM at 48 kHz, written a second at a time (FFmpeg encodes it). */
|
|
133
|
+
async mixdown(options) {
|
|
134
|
+
await load(options.audio)
|
|
135
|
+
let position = 0
|
|
136
|
+
for (let a = 0; a < options.duration; a += 1000) {
|
|
137
|
+
const buffer = mix(a, Math.min(a + 1000, options.duration)), left = buffer.getChannelData(0), right = buffer.getChannelData(1)
|
|
138
|
+
const pcm = new Int16Array(left.length * 2)
|
|
139
|
+
for (let i = 0; i < left.length; i++) { pcm[2 * i] = left[i] * 32767; pcm[2 * i + 1] = right[i] * 32767 }
|
|
140
|
+
const data = new Uint8Array(pcm.buffer)
|
|
141
|
+
await window.presmiumWrite(position, base64(data))
|
|
142
|
+
position += data.length
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
window.presmiumEncoderReady = true
|
|
147
|
+
</script>`
|
|
148
|
+
|
|
149
|
+
interface Hooks {
|
|
150
|
+
presmiumLecture: { duration: number, advance(t: number): Promise<void> }
|
|
151
|
+
presmiumEncoder: { start(options: unknown): Promise<void>, frame(png: string, index: number): Promise<void>, finish(): Promise<void>, mixdown(options: unknown): Promise<void> }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface Page {
|
|
155
|
+
goto(url: string): Promise<unknown>
|
|
156
|
+
waitForFunction(fn: string, arg?: unknown, options?: { timeout?: number }): Promise<unknown>
|
|
157
|
+
evaluate<R, A>(fn: (arg: A) => R | Promise<R>, arg: A): Promise<R>
|
|
158
|
+
exposeFunction(name: string, fn: (...args: never[]) => unknown): Promise<void>
|
|
159
|
+
screenshot(options: { type: 'png' }): Promise<Buffer>
|
|
160
|
+
on(event: 'console' | 'pageerror', listener: (value: { text?(): string, message?: string }) => void): void
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface Chromium {
|
|
164
|
+
launch(): Promise<{
|
|
165
|
+
newContext(options: Record<string, unknown>): Promise<{ newPage(): Promise<Page> }>
|
|
166
|
+
close(): Promise<void>
|
|
167
|
+
}>
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ISO 639-2/T codes of the languages a lecture's captions are likely in (Matroska names a track's language so).
|
|
171
|
+
const ISO_639_2: Record<string, string> = { zh: 'zho', en: 'eng', ja: 'jpn', ko: 'kor', fr: 'fra', de: 'deu', es: 'spa', it: 'ita', pt: 'por', ru: 'rus', ar: 'ara', hi: 'hin', vi: 'vie' }
|
|
172
|
+
|
|
173
|
+
/** The ISO 639-2 code of a caption language: a three-letter language as it is, a two-letter one by the table. */
|
|
174
|
+
function subtitleLanguage(lang: string): string | undefined {
|
|
175
|
+
const primary = lang.split('-')[0]!.toLowerCase()
|
|
176
|
+
return /^[a-z]{3}$/.test(primary) ? primary : ISO_639_2[primary]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface CaptionFile {
|
|
180
|
+
lang: string
|
|
181
|
+
vtt: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The captions the lecture renderer wrote for each language of the lecture. */
|
|
185
|
+
function captionFiles(dir: string, langs: string[]): CaptionFile[] {
|
|
186
|
+
return langs.map(lang => ({ lang, vtt: readFileSync(join(dir, `captions.${lang}.vtt`), 'utf8') }))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function localAudio(track: LectureTrack, base: string): { url: string, start: number, duration: number, sourceStartMs?: number, volume?: number, muted?: boolean }[] {
|
|
190
|
+
return audioSegments(track).filter(segment => !ABSOLUTE_URL.test(segment.src)).map(({ src, ...segment }) => ({ url: `${base}${src}`, ...segment }))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The format of a lecture video whose file is not named: MP4 when FFmpeg can write it, else WebM from the browser. */
|
|
194
|
+
export function defaultVideoFormat(): VideoFormat {
|
|
195
|
+
const ffmpeg = findFfmpeg()
|
|
196
|
+
return ffmpeg && ffmpegEncodes(ffmpeg, 'mp4') ? 'mp4' : 'webm'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function exportVideo(options: ExportOptions): Promise<ExportResult> {
|
|
200
|
+
const deckDir = resolve(options.deckDir)
|
|
201
|
+
const log = options.log ?? (() => {})
|
|
202
|
+
const [width, height, fps] = [options.width ?? 1920, options.height ?? 1080, options.fps ?? 30]
|
|
203
|
+
const work = join(deckDir, '.presmium', 'lecture-export')
|
|
204
|
+
rmSync(work, { recursive: true, force: true })
|
|
205
|
+
try {
|
|
206
|
+
log('building the web form and the lectures…')
|
|
207
|
+
const compilation = await new Compiler(deckDir).compile()
|
|
208
|
+
const actual = compilation.doc.source
|
|
209
|
+
if (options.source && (!actual || actual.documentId !== options.source.documentId || actual.version !== options.source.version || actual.fingerprint !== options.source.fingerprint)) throw new Error('job-source-changed')
|
|
210
|
+
const report = await build(compilation, { outDir: work, to: ['web', 'lecture'] })
|
|
211
|
+
const errors = report.findings.filter(f => f.severity === 'error')
|
|
212
|
+
// The first reason first: a one-line summary (a workbench shows it) says what to fix.
|
|
213
|
+
if (errors.length > 0) throw new Error(`${errors[0]!.message}${errors.length > 1 ? ` (and ${errors.length - 1} more)` : ''}\n${errors.map(f => ` ${f.message}`).join('\n')}`)
|
|
214
|
+
const warnings = report.findings.filter(f => f.severity === 'warning').map(f => f.message)
|
|
215
|
+
const web = report.outputs.find(o => o.renderer === 'web')
|
|
216
|
+
const lectures = report.outputs.find(o => o.renderer === 'lecture')
|
|
217
|
+
if (!web || !lectures) throw new Error('the web form or the lectures were not built')
|
|
218
|
+
const index = JSON.parse(readFileSync(join(lectures.outDir, 'index.json'), 'utf8')) as { id: string, captions?: string[] }[]
|
|
219
|
+
const ids = index.map(entry => entry.id)
|
|
220
|
+
const id = options.track ?? defaultTrack(ids)
|
|
221
|
+
if (!id || !ids.includes(id)) throw new Error(ids.length ? `name the lecture with --track: ${ids.join(', ')}` : LECTURE_MESSAGES[deckLocale(compilation.doc.meta.lang)].production.noPreparedLecture)
|
|
222
|
+
const track = parseTrack(JSON.parse(readFileSync(join(lectures.outDir, id, 'track.json'), 'utf8'))).track!
|
|
223
|
+
const file = resolve(options.out ?? join(deckDir, 'dist', `${id}.${defaultVideoFormat()}`))
|
|
224
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
225
|
+
const format = videoFormat(file), found = findFfmpeg()
|
|
226
|
+
const ffmpeg = found && ffmpegEncodes(found, format) ? found : undefined
|
|
227
|
+
if (!ffmpeg && format === 'mp4') throw new Error(`an MP4 video needs FFmpeg with libx264 and aac: install it (${ffmpegInstallCommand()}) or set PRESMIUM_FFMPEG to its file`)
|
|
228
|
+
if (!ffmpeg) warnings.push(`FFmpeg was not found, so the browser encoded the video as WebM at a lower quality; install it for MP4 at a higher quality: ${ffmpegInstallCommand()}`)
|
|
229
|
+
const captions = captionFiles(join(lectures.outDir, id), index.find(entry => entry.id === id)?.captions ?? [])
|
|
230
|
+
if (options.burn && !captions.some(c => c.lang === options.burn)) throw new Error(`lecture ${id} has no captions in ${options.burn}: ${captions.map(c => c.lang).join(', ') || 'none'}`)
|
|
231
|
+
for (const { lang } of captions) {
|
|
232
|
+
if (!subtitleLanguage(lang)) warnings.push(`the subtitle track of the captions in ${lang} has no language code: its ISO 639-2 code is not known`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const mediabunny = join(dirname(createRequire(import.meta.url).resolve('mediabunny')), 'mediabunny.min.mjs')
|
|
236
|
+
const server = await serveDirectory(work, {
|
|
237
|
+
extra: {
|
|
238
|
+
'/__export/encoder.html': { body: ENCODER, type: 'text/html; charset=utf-8' },
|
|
239
|
+
'/__export/mediabunny.mjs': { file: mediabunny, type: 'text/javascript' },
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
try {
|
|
243
|
+
const browser = await (await playwrightChromium<Chromium>(deckDir, import.meta.url)).launch()
|
|
244
|
+
try {
|
|
245
|
+
// With FFmpeg the encoder page only mixes the sound, into this file; without it, it writes the video.
|
|
246
|
+
const pcm = join(work, 'export-audio.pcm')
|
|
247
|
+
const handle = await open(ffmpeg ? pcm : file, 'w')
|
|
248
|
+
let native: FfmpegEncoder | undefined
|
|
249
|
+
try {
|
|
250
|
+
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1, reducedMotion: 'reduce', serviceWorkers: 'block' })
|
|
251
|
+
const [player, encoder] = [await context.newPage(), await context.newPage()]
|
|
252
|
+
const failures: string[] = []
|
|
253
|
+
for (const page of [player, encoder]) page.on('pageerror', error => failures.push(String(error.message)))
|
|
254
|
+
await player.goto(`${server.url}${web.base}?view=lecture&track=${encodeURIComponent(id)}&export${options.burn ? `&captions=${encodeURIComponent(options.burn)}` : ''}`)
|
|
255
|
+
await player.waitForFunction('Boolean(window.presmiumLecture)', undefined, { timeout: 120_000 })
|
|
256
|
+
const duration = Math.max(trackDuration(track), await player.evaluate(() => (window as unknown as Hooks).presmiumLecture.duration, undefined))
|
|
257
|
+
const frames = Math.max(1, Math.ceil(duration * fps / 1000))
|
|
258
|
+
await encoder.exposeFunction('presmiumWrite', async (position: number, data: string) => {
|
|
259
|
+
const bytes = Buffer.from(data, 'base64')
|
|
260
|
+
await handle.write(bytes, 0, bytes.length, position)
|
|
261
|
+
})
|
|
262
|
+
await encoder.goto(`${server.url}/__export/encoder.html`)
|
|
263
|
+
await encoder.waitForFunction('window.presmiumEncoderReady === true', undefined, { timeout: 60_000 })
|
|
264
|
+
const voice = track.voice
|
|
265
|
+
const label = voice.kind === 'synthetic' ? aigcLabel(voice.provider ?? voice.engine, id) : undefined
|
|
266
|
+
const audio = localAudio(track, `${server.url}${lectures.base}${encodeURIComponent(id)}/`)
|
|
267
|
+
const title = compilation.doc.meta.title
|
|
268
|
+
if (ffmpeg) {
|
|
269
|
+
if (audio.length > 0) await encoder.evaluate(options => (window as unknown as Hooks).presmiumEncoder.mixdown(options), { audio, duration: frames * 1000 / fps })
|
|
270
|
+
const subtitles = captions.map(({ lang, vtt }) => {
|
|
271
|
+
const caption = join(work, `export-captions.${lang}.vtt`)
|
|
272
|
+
writeFileSync(caption, vtt)
|
|
273
|
+
const language = subtitleLanguage(lang)
|
|
274
|
+
return { file: caption, name: lang, ...language ? { language } : {} }
|
|
275
|
+
})
|
|
276
|
+
native = startFfmpeg(ffmpeg, {
|
|
277
|
+
out: file,
|
|
278
|
+
fps,
|
|
279
|
+
...audio.length > 0 ? { audio: pcm } : {},
|
|
280
|
+
subtitles,
|
|
281
|
+
metadata: { ...title ? { title } : {}, ...label ? { comment: aigcString(label), AIGC: JSON.stringify(label) } : {} },
|
|
282
|
+
})
|
|
283
|
+
} else {
|
|
284
|
+
await encoder.evaluate(options => (window as unknown as Hooks).presmiumEncoder.start(options), {
|
|
285
|
+
width,
|
|
286
|
+
height,
|
|
287
|
+
fps,
|
|
288
|
+
duration: frames * 1000 / fps,
|
|
289
|
+
audio,
|
|
290
|
+
subtitles: captions.map(({ lang, vtt }) => ({ language: subtitleLanguage(lang), name: lang, vtt })),
|
|
291
|
+
metadata: { title, ...label ? { comment: aigcString(label), raw: { AIGC: JSON.stringify(label) } } : {} },
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
log(`playing lecture ${id}: ${frames} frames at ${fps} fps, ${width}x${height}, ${ffmpeg ? `${format.toUpperCase()} by FFmpeg` : 'WebM by the browser'}`)
|
|
295
|
+
for (let i = 0; i < frames; i++) {
|
|
296
|
+
options.signal?.throwIfAborted()
|
|
297
|
+
const at = Math.round(i * 1000 / fps)
|
|
298
|
+
await player.evaluate(t => (window as unknown as Hooks).presmiumLecture.advance(t), at).catch((error: unknown) => {
|
|
299
|
+
throw new Error(`the frame at ${clockText(at)} cannot be drawn: ${errorMessage(error).replace(/^page\.evaluate: (Error: )?/, '')}`)
|
|
300
|
+
})
|
|
301
|
+
// PNG: a JPEG frame would blur the text before the encoder sees it.
|
|
302
|
+
const png = await player.screenshot({ type: 'png' })
|
|
303
|
+
if (native) await native.frame(png)
|
|
304
|
+
else await encoder.evaluate(([data, n]) => (window as unknown as Hooks).presmiumEncoder.frame(data, n), [png.toString('base64'), i] as [string, number])
|
|
305
|
+
if (failures.length) throw new Error(failures.join('\n'))
|
|
306
|
+
if ((i + 1) % Math.max(1, Math.round(frames / 10)) === 0) log(` ${Math.round((i + 1) * 100 / frames)}%`)
|
|
307
|
+
options.progress?.(i + 1, frames)
|
|
308
|
+
}
|
|
309
|
+
if (native) await native.finish()
|
|
310
|
+
else await encoder.evaluate(() => (window as unknown as Hooks).presmiumEncoder.finish(), undefined)
|
|
311
|
+
// The captions go beside the video once the video is whole.
|
|
312
|
+
const sidecars = captions.map(({ lang, vtt }) => {
|
|
313
|
+
const sidecar = `${file.replace(/\.(webm|mp4)$/i, '')}.${lang}.vtt`
|
|
314
|
+
writeFileSync(sidecar, vtt)
|
|
315
|
+
return { lang, file: sidecar }
|
|
316
|
+
})
|
|
317
|
+
return { file, track: id, frames, duration: frames * 1000 / fps, captions: sidecars, warnings }
|
|
318
|
+
} finally {
|
|
319
|
+
native?.kill()
|
|
320
|
+
await handle.close()
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
await browser.close()
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
await server.close()
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
rmSync(work, { recursive: true, force: true })
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { once } from 'node:events'
|
|
3
|
+
import type { Ffmpeg } from '@lincyaw/kernel/node'
|
|
4
|
+
|
|
5
|
+
export type VideoFormat = 'mp4' | 'webm'
|
|
6
|
+
|
|
7
|
+
const ENCODERS: Record<VideoFormat, string[]> = { mp4: ['libx264', 'aac'], webm: ['libvpx-vp9', 'libopus'] }
|
|
8
|
+
|
|
9
|
+
export function videoFormat(file: string): VideoFormat {
|
|
10
|
+
return file.toLowerCase().endsWith('.webm') ? 'webm' : 'mp4'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whether this FFmpeg has the encoders of the format. */
|
|
14
|
+
export function ffmpegEncodes(ffmpeg: Ffmpeg, format: VideoFormat): boolean {
|
|
15
|
+
return ENCODERS[format].every(name => ffmpeg.encoders.has(name))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FfmpegVideo {
|
|
19
|
+
out: string
|
|
20
|
+
fps: number
|
|
21
|
+
/** 16-bit stereo PCM at 48 kHz, the whole lecture's sound. */
|
|
22
|
+
audio?: string
|
|
23
|
+
subtitles: { file: string, name: string, language?: string }[]
|
|
24
|
+
metadata: Record<string, string>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FfmpegEncoder {
|
|
28
|
+
frame(png: Buffer): Promise<void>
|
|
29
|
+
finish(): Promise<void>
|
|
30
|
+
kill(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An FFmpeg process that takes the frames as PNG on its input and writes the video with its sound and subtitle tracks. */
|
|
34
|
+
export function startFfmpeg(ffmpeg: Ffmpeg, video: FfmpegVideo): FfmpegEncoder {
|
|
35
|
+
const format = videoFormat(video.out)
|
|
36
|
+
const args = ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-c:v', 'png', '-framerate', String(video.fps), '-i', '-']
|
|
37
|
+
if (video.audio) args.push('-f', 's16le', '-ar', '48000', '-ac', '2', '-i', video.audio)
|
|
38
|
+
for (const { file } of video.subtitles) args.push('-i', file)
|
|
39
|
+
args.push('-map', '0:v')
|
|
40
|
+
if (video.audio) args.push('-map', '1:a')
|
|
41
|
+
video.subtitles.forEach((_, i) => args.push('-map', `${i + (video.audio ? 2 : 1)}:s`))
|
|
42
|
+
// Even sides for 4:2:0; slides are text, so the quality is set by a constant rate factor rather than a bitrate.
|
|
43
|
+
args.push('-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-pix_fmt', 'yuv420p', '-g', String(Math.round(video.fps * 10)))
|
|
44
|
+
// FFmpeg's AAC noise substitution turns some music into bursts above the source's true peak; it is off.
|
|
45
|
+
if (format === 'mp4') args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', '-aac_pns', '0', '-c:s', 'mov_text', '-movflags', '+faststart+use_metadata_tags')
|
|
46
|
+
else args.push('-c:v', 'libvpx-vp9', '-crf', '24', '-b:v', '0', '-deadline', 'good', '-cpu-used', '2', '-row-mt', '1', '-c:a', 'libopus', '-b:a', '128k', '-c:s', 'webvtt')
|
|
47
|
+
video.subtitles.forEach(({ name, language }, i) => {
|
|
48
|
+
if (language) args.push(`-metadata:s:s:${i}`, `language=${language}`)
|
|
49
|
+
args.push(`-metadata:s:s:${i}`, `title=${name}`)
|
|
50
|
+
})
|
|
51
|
+
for (const [key, value] of Object.entries(video.metadata)) args.push('-metadata', `${key}=${value}`)
|
|
52
|
+
args.push(video.out)
|
|
53
|
+
|
|
54
|
+
const child = spawn(ffmpeg.file, args, { stdio: ['pipe', 'ignore', 'pipe'] })
|
|
55
|
+
let stderr = ''
|
|
56
|
+
child.stderr.setEncoding('utf8').on('data', (text: string) => { stderr = (stderr + text).slice(-4000) })
|
|
57
|
+
child.stdin.on('error', () => {})
|
|
58
|
+
const exited = new Promise<void>((resolve, reject) => {
|
|
59
|
+
child.on('error', reject)
|
|
60
|
+
child.on('close', (code, signal) => code === 0 ? resolve() : reject(new Error(`ffmpeg stopped (${code ?? signal}): ${stderr.trim() || 'no message'}`)))
|
|
61
|
+
})
|
|
62
|
+
exited.catch(() => {})
|
|
63
|
+
return {
|
|
64
|
+
async frame(png) {
|
|
65
|
+
if (child.exitCode !== null || !child.stdin.writable) return exited
|
|
66
|
+
if (!child.stdin.write(png)) await Promise.race([once(child.stdin, 'drain'), exited])
|
|
67
|
+
},
|
|
68
|
+
async finish() {
|
|
69
|
+
child.stdin.end()
|
|
70
|
+
await exited
|
|
71
|
+
},
|
|
72
|
+
kill() {
|
|
73
|
+
if (child.exitCode === null) child.kill('SIGKILL')
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { viewFor } from '@lincyaw/compiler'
|
|
3
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
4
|
+
import type { IRDocument, IRPass } from '@lincyaw/kernel'
|
|
5
|
+
import { configuredLectureVoice, LECTURE_ID, LECTURE_RENDERER, prepareNarrationVoices } from '../domain/index.ts'
|
|
6
|
+
import { deckPlan } from './plan.ts'
|
|
7
|
+
import { trackIds } from './store.ts'
|
|
8
|
+
|
|
9
|
+
const views = new WeakMap<IRDocument, IRDocument>()
|
|
10
|
+
|
|
11
|
+
export function lectureView(doc: IRDocument): IRDocument {
|
|
12
|
+
let view = views.get(doc)
|
|
13
|
+
if (!view) views.set(doc, view = viewFor(doc, LECTURE_RENDERER))
|
|
14
|
+
return view
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const latest = new Map<string, IRDocument>()
|
|
18
|
+
|
|
19
|
+
export function latestDoc(deckDir: string): IRDocument | undefined {
|
|
20
|
+
return latest.get(deckDir)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function latestView(deckDir: string): IRDocument | undefined {
|
|
24
|
+
const doc = latest.get(deckDir)
|
|
25
|
+
return doc && lectureView(doc)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rememberLatestIr(doc: IRDocument, deckDir: string): void {
|
|
29
|
+
const remembered = latest.get(deckDir)
|
|
30
|
+
const translation = Boolean(doc.meta.translationOf) || Object.values(remembered?.config.translations ?? {}).some(entry => resolve(deckDir, entry) === doc.meta.entry)
|
|
31
|
+
if (!translation) latest.set(deckDir, doc)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeLecturesFlag(doc: IRDocument, deckDir: string): void {
|
|
35
|
+
const own = isRecord(doc.config.presmium[LECTURE_ID]) ? doc.config.presmium[LECTURE_ID] as Record<string, unknown> : {}
|
|
36
|
+
const renderers = Object.keys(doc.config.renderers)
|
|
37
|
+
const built = renderers.length === 0 || renderers.includes(LECTURE_RENDERER)
|
|
38
|
+
const silent = deckPlan(doc).silent
|
|
39
|
+
const lectures = typeof own.url === 'string' || (built && (Boolean(configuredLectureVoice(doc)) || silent || trackIds(deckDir).length > 0))
|
|
40
|
+
doc.config.presmium = { ...doc.config.presmium, [LECTURE_ID]: { ...own, lectures } }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const rememberLatestIrPass: IRPass = {
|
|
44
|
+
name: 'presmium.lecture.keep',
|
|
45
|
+
after: 'execute',
|
|
46
|
+
order: 1000,
|
|
47
|
+
run(doc, ctx) {
|
|
48
|
+
try { prepareNarrationVoices(doc) } catch (error) {
|
|
49
|
+
ctx.report({ checkId: LECTURE_ID, severity: 'error', message: error instanceof Error ? error.message : String(error), source: { file: doc.meta.entry, line: 1 } })
|
|
50
|
+
}
|
|
51
|
+
rememberLatestIr(doc, ctx.deckDir)
|
|
52
|
+
writeLecturesFlag(doc, ctx.deckDir)
|
|
53
|
+
},
|
|
54
|
+
}
|
package/src/node/plan.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { Finding, IRDocument } from '@lincyaw/kernel'
|
|
3
|
+
import { configuredLectureVoice, LECTURE_ID, lectureGap, lecturePlan, lexiconOf, plannedUtterances } from '../domain/index.ts'
|
|
4
|
+
import type { LecturePlan, LectureVoice } from '../domain/index.ts'
|
|
5
|
+
import { productionDiagnostics } from '../domain/production.ts'
|
|
6
|
+
|
|
7
|
+
export interface DeckPlan {
|
|
8
|
+
plan: LecturePlan
|
|
9
|
+
voice?: LectureVoice
|
|
10
|
+
silent: boolean
|
|
11
|
+
recorded: boolean
|
|
12
|
+
lang: string
|
|
13
|
+
lexicon?: Record<string, string>
|
|
14
|
+
gap: number
|
|
15
|
+
findings: Finding[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function lectureConfig(doc: IRDocument): Record<string, unknown> {
|
|
19
|
+
const config = doc.config.renderers.lecture
|
|
20
|
+
return isRecord(config) ? config : {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deckPlan(doc: IRDocument): DeckPlan {
|
|
24
|
+
const config = lectureConfig(doc)
|
|
25
|
+
const findings: Finding[] = []
|
|
26
|
+
const plan = lecturePlan(doc)
|
|
27
|
+
findings.push(...plan.problems.filter(p => !p.reportedAtCompile).map((p): Finding => ({ checkId: LECTURE_ID, severity: 'warning', message: p.message, ...p.source ? { source: { file: p.source.file, line: p.source.line } } : {} })))
|
|
28
|
+
findings.push(...productionDiagnostics(doc, plan).map(issue => ({ checkId: LECTURE_ID, severity: issue.severity, message: issue.message, source: issue.sectionId ? doc.sections.find(section => section.id === issue.sectionId)?.source : { file: doc.meta.entry, line: 1 } })))
|
|
29
|
+
const silent = plannedUtterances(plan).length === 0 || Boolean(doc.production && [doc.production.defaults, ...Object.values(doc.production.sectionOverrides ?? {}), ...Object.values(doc.production.stepOverrides)].some(setting => setting.voiceSource?.type === 'none'))
|
|
30
|
+
const own = doc.config.presmium[LECTURE_ID]
|
|
31
|
+
const voice = configuredLectureVoice(doc)
|
|
32
|
+
const lexicon = lexiconOf(isRecord(own) ? own : undefined)
|
|
33
|
+
const recorded = plan.sections.some(section => section.recordings?.length)
|
|
34
|
+
return { plan, silent, recorded, ...voice ? { voice } : {}, lang: doc.meta.lang ?? 'zh-CN', ...lexicon ? { lexicon } : {}, gap: lectureGap(config), findings }
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { PRODUCTION_AUDIO_AUTHORING, VOICEOVER, VOICEOVER_EXECUTION } from '@lincyaw/kernel'
|
|
4
|
+
import type { Exporter, PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
5
|
+
import { LECTURE_ID } from '../domain/index.ts'
|
|
6
|
+
import { lectureCheck } from './check.ts'
|
|
7
|
+
import { defaultVideoFormat, exportVideo } from './export.ts'
|
|
8
|
+
import { rememberLatestIrPass } from './latest-ir.ts'
|
|
9
|
+
import { lectureRenderer } from './renderer.ts'
|
|
10
|
+
import { cueRole, sayRole } from './roles.ts'
|
|
11
|
+
import { lectureServer } from './routes/server.ts'
|
|
12
|
+
import { LECTURE_DECK, lectureTools } from './tools.ts'
|
|
13
|
+
import { productionAudioAuthoring } from './audio-authoring.ts'
|
|
14
|
+
import { voiceoverExecution } from './voiceover-execution.ts'
|
|
15
|
+
|
|
16
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
17
|
+
|
|
18
|
+
/** The narrated lecture as a WebM video with its captions, as `presmium-lecture export` makes it. */
|
|
19
|
+
const videoExporter: Exporter = {
|
|
20
|
+
id: 'video',
|
|
21
|
+
label: { 'zh-CN': '讲授视频', 'en': 'Lecture video' },
|
|
22
|
+
available: (doc) => {
|
|
23
|
+
const named = Object.keys(doc.config.renderers)
|
|
24
|
+
return named.length === 0 || named.includes('lecture')
|
|
25
|
+
},
|
|
26
|
+
async run(ctx) {
|
|
27
|
+
ctx.progress('build')
|
|
28
|
+
if (!ctx.source) throw new Error('job-source-not-compiled')
|
|
29
|
+
const result = await exportVideo({
|
|
30
|
+
deckDir: ctx.deckDir,
|
|
31
|
+
source: ctx.source,
|
|
32
|
+
out: join(ctx.outDir, `lecture.${defaultVideoFormat()}`),
|
|
33
|
+
log: ctx.log,
|
|
34
|
+
progress: (done, total) => ctx.progress('frames', done, total),
|
|
35
|
+
signal: ctx.signal,
|
|
36
|
+
})
|
|
37
|
+
return {
|
|
38
|
+
files: [basename(result.file), ...result.captions.map(({ file }) => basename(file))],
|
|
39
|
+
captions: Object.fromEntries(result.captions.map(({ lang, file }) => [lang, basename(file)])),
|
|
40
|
+
warnings: result.warnings,
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const plugin: PresmiumNodePlugin = {
|
|
46
|
+
id: LECTURE_ID,
|
|
47
|
+
version,
|
|
48
|
+
kernel: '^1.0.0',
|
|
49
|
+
checks: [lectureCheck],
|
|
50
|
+
renderer: lectureRenderer,
|
|
51
|
+
server: lectureServer,
|
|
52
|
+
roles: [cueRole, sayRole],
|
|
53
|
+
passes: [rememberLatestIrPass],
|
|
54
|
+
services: ctx => [[LECTURE_DECK, { deckDir: ctx.deckDir, workspaceRoot: ctx.workspaceRoot }], [VOICEOVER, { version: 1, preview: true }], [VOICEOVER_EXECUTION, voiceoverExecution(ctx)], [PRODUCTION_AUDIO_AUTHORING, productionAudioAuthoring(ctx)]],
|
|
55
|
+
tools: lectureTools,
|
|
56
|
+
exporters: [videoExporter],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default plugin
|