@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,169 @@
|
|
|
1
|
+
import type { Store } from '@lincyaw/host'
|
|
2
|
+
import { createStore } from '@lincyaw/host'
|
|
3
|
+
|
|
4
|
+
export interface TrackClock {
|
|
5
|
+
now(): number
|
|
6
|
+
readonly playing: Readonly<Store<boolean>>
|
|
7
|
+
readonly rate: Readonly<Store<number>>
|
|
8
|
+
readonly error: Readonly<Store<Error | undefined>>
|
|
9
|
+
play(): void
|
|
10
|
+
pause(): void
|
|
11
|
+
seek(t: number): void
|
|
12
|
+
setRate(rate: number): void
|
|
13
|
+
tick(): void
|
|
14
|
+
dispose(): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SilentClock implements TrackClock {
|
|
18
|
+
readonly playing = createStore(false)
|
|
19
|
+
readonly rate = createStore(1)
|
|
20
|
+
readonly error = createStore<Error | undefined>(undefined)
|
|
21
|
+
protected at = 0
|
|
22
|
+
protected since = performance.now()
|
|
23
|
+
protected readonly duration: number
|
|
24
|
+
|
|
25
|
+
constructor(duration: number) {
|
|
26
|
+
this.duration = duration
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
now(): number {
|
|
30
|
+
const t = this.playing.getSnapshot() ? this.at + (performance.now() - this.since) * this.rate.getSnapshot() : this.at
|
|
31
|
+
return Math.min(t, this.duration)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
play(): void {
|
|
35
|
+
if (this.playing.getSnapshot()) return
|
|
36
|
+
if (this.at >= this.duration) this.at = 0
|
|
37
|
+
this.since = performance.now()
|
|
38
|
+
this.playing.set(true)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pause(): void {
|
|
42
|
+
this.at = this.now()
|
|
43
|
+
this.playing.set(false)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
seek(t: number): void {
|
|
47
|
+
this.at = Math.max(0, Math.min(t, this.duration))
|
|
48
|
+
this.since = performance.now()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setRate(rate: number): void {
|
|
52
|
+
this.at = this.now()
|
|
53
|
+
this.since = performance.now()
|
|
54
|
+
this.rate.set(rate)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
tick(): void {
|
|
58
|
+
if (this.playing.getSnapshot() && this.now() >= this.duration) this.pause()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dispose(): void {
|
|
62
|
+
this.pause()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ClockMedia {
|
|
67
|
+
url: string
|
|
68
|
+
start: number
|
|
69
|
+
duration: number
|
|
70
|
+
sourceStartMs?: number
|
|
71
|
+
volume?: number
|
|
72
|
+
muted?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DRIFT_MS = 250
|
|
76
|
+
|
|
77
|
+
export class MediaClock extends SilentClock {
|
|
78
|
+
private readonly media: { el: HTMLAudioElement, start: number, duration: number, sourceStartMs: number }[]
|
|
79
|
+
|
|
80
|
+
constructor(duration: number, media: ClockMedia[]) {
|
|
81
|
+
super(duration)
|
|
82
|
+
this.media = media.map(({ url, start, duration: length, sourceStartMs = 0, volume = 1, muted = false }) => {
|
|
83
|
+
const el = new Audio()
|
|
84
|
+
el.preload = 'auto'
|
|
85
|
+
el.onerror = () => this.fail(new Error([url, el.error?.message].filter(Boolean).join(': ')))
|
|
86
|
+
el.src = url
|
|
87
|
+
el.volume = volume
|
|
88
|
+
el.muted = muted
|
|
89
|
+
return { el, start, duration: length, sourceStartMs }
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fail(error: Error): void {
|
|
94
|
+
this.pause()
|
|
95
|
+
this.error.set(error)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private mediaAt(t: number) {
|
|
99
|
+
return this.media.find(m => t >= m.start && t < m.start + m.duration)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override now(): number {
|
|
103
|
+
const current = this.playing.getSnapshot() ? this.mediaAt(super.now()) : undefined
|
|
104
|
+
if (current && !current.el.paused && current.el.readyState >= 2) {
|
|
105
|
+
const t = Math.min(current.start + current.duration, current.start + current.el.currentTime * 1000 - current.sourceStartMs)
|
|
106
|
+
this.at = Math.min(t, this.duration)
|
|
107
|
+
this.since = performance.now()
|
|
108
|
+
return this.at
|
|
109
|
+
}
|
|
110
|
+
return super.now()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
override play(): void {
|
|
114
|
+
super.play()
|
|
115
|
+
this.tick()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
override pause(): void {
|
|
119
|
+
super.pause()
|
|
120
|
+
for (const { el } of this.media) el.pause()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
override seek(t: number): void {
|
|
124
|
+
super.seek(t)
|
|
125
|
+
for (const m of this.media) {
|
|
126
|
+
if (t >= m.start && t < m.start + m.duration) m.el.currentTime = (m.sourceStartMs + t - m.start) / 1000
|
|
127
|
+
else m.el.pause()
|
|
128
|
+
}
|
|
129
|
+
this.tick()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override setRate(rate: number): void {
|
|
133
|
+
super.setRate(rate)
|
|
134
|
+
for (const { el } of this.media) el.playbackRate = rate
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override tick(): void {
|
|
138
|
+
super.tick()
|
|
139
|
+
const t = this.now()
|
|
140
|
+
for (const m of this.media) {
|
|
141
|
+
if (!this.playing.getSnapshot() || t < m.start || t >= m.start + m.duration) {
|
|
142
|
+
if (!m.el.paused) m.el.pause()
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const offset = (m.sourceStartMs + t - m.start) / 1000
|
|
146
|
+
if (m.el.paused || Math.abs(m.el.currentTime - offset) * 1000 > DRIFT_MS) m.el.currentTime = offset
|
|
147
|
+
m.el.playbackRate = this.rate.getSnapshot()
|
|
148
|
+
if (m.el.paused) void m.el.play().then(() => {
|
|
149
|
+
const at = super.now()
|
|
150
|
+
if (!this.playing.getSnapshot() || at < m.start || at >= m.start + m.duration) m.el.pause()
|
|
151
|
+
}).catch((error: unknown) => {
|
|
152
|
+
if (error instanceof DOMException && error.name === 'AbortError') return
|
|
153
|
+
this.fail(error instanceof Error ? error : new Error(String(error)))
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
override dispose(): void {
|
|
159
|
+
super.dispose()
|
|
160
|
+
for (const { el } of this.media) {
|
|
161
|
+
el.pause()
|
|
162
|
+
el.onerror = null
|
|
163
|
+
const url = el.src
|
|
164
|
+
el.removeAttribute('src')
|
|
165
|
+
el.load()
|
|
166
|
+
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { canEncodeAudio, getFirstEncodableVideoCodec, MediaStreamAudioTrackSource, MediaStreamVideoTrackSource, OggOutputFormat, Output, Quality, StreamTarget, WebMOutputFormat } from 'mediabunny'
|
|
2
|
+
|
|
3
|
+
export interface Chunk {
|
|
4
|
+
data: Uint8Array<ArrayBuffer>
|
|
5
|
+
position: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type Sink = (chunk: Chunk) => void
|
|
9
|
+
|
|
10
|
+
export interface Capture {
|
|
11
|
+
ext: 'ogg' | 'webm' | 'mp4'
|
|
12
|
+
stop(): Promise<void>
|
|
13
|
+
failed: Promise<never>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SPEECH = new Quality({ bitrate: 24_000 })
|
|
17
|
+
|
|
18
|
+
function target(sink: Sink): StreamTarget {
|
|
19
|
+
return new StreamTarget(new WritableStream({ write: chunk => sink({ data: chunk.data, position: chunk.position }) }))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function withOutput(output: Output, clone: MediaStreamTrack, source: MediaStreamAudioTrackSource | MediaStreamVideoTrackSource, ext: Capture['ext']): Promise<Capture> {
|
|
23
|
+
if (source instanceof MediaStreamAudioTrackSource) output.addAudioTrack(source)
|
|
24
|
+
else output.addVideoTrack(source)
|
|
25
|
+
const failed = source.errorPromise.then(() => new Promise<never>(() => {}))
|
|
26
|
+
await output.start()
|
|
27
|
+
return {
|
|
28
|
+
ext,
|
|
29
|
+
failed,
|
|
30
|
+
async stop() {
|
|
31
|
+
await output.finalize()
|
|
32
|
+
clone.stop()
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function withRecorder(clone: MediaStreamTrack, sink: Sink): Capture | undefined {
|
|
38
|
+
if (typeof MediaRecorder === 'undefined') return undefined
|
|
39
|
+
const type = ['audio/ogg;codecs=opus', 'audio/webm;codecs=opus', 'audio/mp4'].find(t => MediaRecorder.isTypeSupported(t))
|
|
40
|
+
if (!type) return undefined
|
|
41
|
+
const recorder = new MediaRecorder(new MediaStream([clone]), { mimeType: type, audioBitsPerSecond: 24_000 })
|
|
42
|
+
let position = 0
|
|
43
|
+
let writing = Promise.resolve()
|
|
44
|
+
recorder.ondataavailable = (event) => {
|
|
45
|
+
writing = writing.then(async () => {
|
|
46
|
+
const data = new Uint8Array(await event.data.arrayBuffer())
|
|
47
|
+
sink({ data, position })
|
|
48
|
+
position += data.byteLength
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
const failed = new Promise<never>((_, fail) => {
|
|
52
|
+
recorder.onerror = event => fail(new Error(String((event as ErrorEvent).message ?? 'MediaRecorder failed')))
|
|
53
|
+
})
|
|
54
|
+
recorder.start(1000)
|
|
55
|
+
return {
|
|
56
|
+
ext: type.startsWith('audio/ogg') ? 'ogg' : type.startsWith('audio/webm') ? 'webm' : 'mp4',
|
|
57
|
+
failed,
|
|
58
|
+
stop: () => new Promise((done) => {
|
|
59
|
+
recorder.onstop = () => void writing.then(() => {
|
|
60
|
+
clone.stop()
|
|
61
|
+
done()
|
|
62
|
+
})
|
|
63
|
+
recorder.stop()
|
|
64
|
+
}),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function captureAudio(track: MediaStreamTrack, sink: Sink): Promise<Capture | undefined> {
|
|
69
|
+
const clone = track.clone()
|
|
70
|
+
if (await canEncodeAudio('opus').catch(() => false)) {
|
|
71
|
+
const output = new Output({ format: new OggOutputFormat({ maximumPageDuration: 1 }), target: target(sink) })
|
|
72
|
+
return withOutput(output, clone, new MediaStreamAudioTrackSource(clone as MediaStreamAudioTrack, { codec: 'opus', quality: SPEECH }), 'ogg')
|
|
73
|
+
}
|
|
74
|
+
const fallback = withRecorder(clone, sink)
|
|
75
|
+
if (!fallback) clone.stop()
|
|
76
|
+
return fallback
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function captureVideo(track: MediaStreamTrack, sink: Sink): Promise<Capture | undefined> {
|
|
80
|
+
const { width, height } = track.getSettings()
|
|
81
|
+
const codec = await getFirstEncodableVideoCodec(['vp9', 'vp8', 'av1'], { width, height }).catch(() => null)
|
|
82
|
+
if (!codec) return undefined
|
|
83
|
+
const clone = track.clone()
|
|
84
|
+
const output = new Output({ format: new WebMOutputFormat({ minimumClusterDuration: 1 }), target: target(sink) })
|
|
85
|
+
return withOutput(output, clone, new MediaStreamVideoTrackSource(clone as MediaStreamVideoTrack, { codec, quality: new Quality('medium') }), 'webm')
|
|
86
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { OPERATIONS, PLAYBACK_CONTROL, PRODUCTION_PLAYBACK, STEP_CLOCK, useMessages, useServer, useService, useStore } from '@lincyaw/host'
|
|
2
|
+
import type { Operations, StepClock } from '@lincyaw/host'
|
|
3
|
+
import { errorMessage, PREVIEW_REVISION, sameProductionBinding } from '@lincyaw/kernel'
|
|
4
|
+
import type { ProductionBinding, ProductionPlan } from '@lincyaw/kernel'
|
|
5
|
+
import { flushSync } from 'react-dom'
|
|
6
|
+
import { useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { audioSegments, LECTURE_ID, LECTURE_MESSAGES, SCRIPT_TRACK, timedEvents, trackDuration } from '../domain/index.ts'
|
|
8
|
+
import type { LectureTrack } from '../domain/index.ts'
|
|
9
|
+
import type { ClockMedia, TrackClock } from './clock.ts'
|
|
10
|
+
import { MediaClock, SilentClock } from './clock.ts'
|
|
11
|
+
import { LecturePlayer } from './player.ts'
|
|
12
|
+
import type { LectureSource } from './source.ts'
|
|
13
|
+
import { lectureSource } from './source.ts'
|
|
14
|
+
import { ProductionPlaybackController, settleProductionPlayback } from './production-playback.ts'
|
|
15
|
+
import { exporting, play, playing, useViewer } from './state.ts'
|
|
16
|
+
|
|
17
|
+
async function createClock(track: LectureTrack, source: LectureSource, signal?: AbortSignal): Promise<TrackClock> {
|
|
18
|
+
const length = trackDuration(track)
|
|
19
|
+
const segments = audioSegments(track)
|
|
20
|
+
if (!segments.length) return new SilentClock(length)
|
|
21
|
+
const loaded = await Promise.allSettled(segments.map(async (segment): Promise<ClockMedia> => {
|
|
22
|
+
if (track.binding && source.productionMedia && !segment.clipId) throw new Error('production-clip-unavailable')
|
|
23
|
+
const url = track.binding && source.productionMedia && segment.clipId ? await source.productionMedia(track.binding, segment.clipId, signal) : await source.media(track.id, segment.src)
|
|
24
|
+
return { url, start: segment.start, duration: segment.duration, sourceStartMs: segment.sourceStartMs, volume: segment.volume, muted: segment.muted }
|
|
25
|
+
}))
|
|
26
|
+
const media = loaded.flatMap(result => result.status === 'fulfilled' ? [result.value] : [])
|
|
27
|
+
const failed = loaded.find(result => result.status === 'rejected')
|
|
28
|
+
if (failed || signal?.aborted) { for (const { url } of media) if (url.startsWith('blob:')) URL.revokeObjectURL(url); if (failed) throw failed.reason; signal?.throwIfAborted() }
|
|
29
|
+
return new MediaClock(length, media)
|
|
30
|
+
}
|
|
31
|
+
function installExportHook(player: LecturePlayer, clock: StepClock | undefined): void {
|
|
32
|
+
const frame = () => new Promise<void>(done => requestAnimationFrame(() => done()))
|
|
33
|
+
Object.assign(window, { presmiumLecture: { duration: player.duration, async advance(at: number) {
|
|
34
|
+
flushSync(() => player.advanceTo(at)); await clock?.settled(); await frame(); await frame()
|
|
35
|
+
} } })
|
|
36
|
+
}
|
|
37
|
+
export function useLecturePlayback() {
|
|
38
|
+
const viewer = useViewer()
|
|
39
|
+
const doc = useStore(viewer.doc)
|
|
40
|
+
const server = useServer()
|
|
41
|
+
const operations = useService(OPERATIONS)
|
|
42
|
+
const stepClock = useService(STEP_CLOCK)
|
|
43
|
+
const playback = useService(PLAYBACK_CONTROL)
|
|
44
|
+
const provided = useService(PRODUCTION_PLAYBACK)
|
|
45
|
+
const production = provided instanceof ProductionPlaybackController ? provided : undefined
|
|
46
|
+
const t = useMessages(LECTURE_MESSAGES)
|
|
47
|
+
const [player, setPlayer] = useState<LecturePlayer>()
|
|
48
|
+
const [status, setStatus] = useState<'loading' | 'missing' | 'ready' | 'failed' | 'estimated' | 'unavailable'>()
|
|
49
|
+
const [failure, setFailure] = useState<string>()
|
|
50
|
+
const [language, setLanguage] = useState<string>()
|
|
51
|
+
const current = useRef<LecturePlayer>()
|
|
52
|
+
const generation = useRef(0)
|
|
53
|
+
const releaseClock = useRef<() => void>(() => {})
|
|
54
|
+
const releaseProduction = useRef<() => void>(() => {})
|
|
55
|
+
const clear = () => { releaseProduction.current(); releaseProduction.current = () => {}; current.current?.dispose(); current.current = undefined; releaseClock.current(); releaseClock.current = () => {} }
|
|
56
|
+
const start = async (track: LectureTrack, source: LectureSource, ops: Operations, load: number, at = 0, plan?: ProductionPlan, signal?: AbortSignal, origin?: { section: string, step: number }) => {
|
|
57
|
+
const clock = await createClock(track, source, signal)
|
|
58
|
+
if (load !== generation.current || signal?.aborted) { clock.dispose(); signal?.throwIfAborted(); return }
|
|
59
|
+
clear()
|
|
60
|
+
const next = new LecturePlayer(track, doc, ops, clock, playback, Boolean(plan))
|
|
61
|
+
current.current = next
|
|
62
|
+
releaseClock.current = stepClock?.drive({ elapsed: next.stepTime, recording: exporting }) ?? (() => {})
|
|
63
|
+
const seek = () => {
|
|
64
|
+
if (origin && next.seekStep(origin.section, origin.step)) return
|
|
65
|
+
if (origin && plan) { clear(); throw new Error('production-binding-stale') }
|
|
66
|
+
next.seek(at)
|
|
67
|
+
}
|
|
68
|
+
if (plan) flushSync(seek)
|
|
69
|
+
else seek()
|
|
70
|
+
if (plan && production) {
|
|
71
|
+
await settleProductionPlayback(stepClock, signal)
|
|
72
|
+
if (load !== generation.current || signal?.aborted) { signal?.throwIfAborted(); return }
|
|
73
|
+
releaseProduction.current = production.attach(plan, next, stepClock)
|
|
74
|
+
}
|
|
75
|
+
setPlayer(next); setLanguage(old => old ?? track.lang); setStatus('ready')
|
|
76
|
+
if (exporting) installExportHook(next, stepClock)
|
|
77
|
+
}
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
clear(); setPlayer(undefined); setFailure(undefined)
|
|
80
|
+
if (!operations) { setStatus(undefined); return }
|
|
81
|
+
const source = lectureSource(server, doc.config.presmium[LECTURE_ID])
|
|
82
|
+
const currentBinding = (binding: ProductionBinding) => viewer.doc.getSnapshot() === doc && doc.source?.documentId === binding.source.documentId && doc.source.version === binding.source.version && doc.source.fingerprint === binding.source.fingerprint && doc.meta[PREVIEW_REVISION] === binding.irRevision
|
|
83
|
+
const loadTrack = async (id: string | undefined, binding?: ProductionBinding, signal?: AbortSignal) => {
|
|
84
|
+
const load = ++generation.current
|
|
85
|
+
const check = () => { signal?.throwIfAborted(); if (load !== generation.current || viewer.doc.getSnapshot() !== doc) throw new Error('production-binding-stale') }
|
|
86
|
+
if (binding && !currentBinding(binding)) throw new Error('production-binding-stale')
|
|
87
|
+
current.current?.pause(); setFailure(undefined)
|
|
88
|
+
if (!id) { clear(); setPlayer(undefined); setStatus(undefined); return }
|
|
89
|
+
setStatus('loading')
|
|
90
|
+
let plan: ProductionPlan | undefined
|
|
91
|
+
if (binding) {
|
|
92
|
+
if (!source.plan || !production) throw new Error('production-playback-unavailable')
|
|
93
|
+
plan = await source.plan({ source: binding.source, irRevision: binding.irRevision }, signal); check()
|
|
94
|
+
if (!sameProductionBinding(plan.binding, binding)) throw new Error('production-binding-stale')
|
|
95
|
+
clear(); setPlayer(undefined); production.loading(binding)
|
|
96
|
+
if (!plan.canPreview) {
|
|
97
|
+
releaseProduction.current = production.attach(plan)
|
|
98
|
+
setStatus(plan.timing === 'estimated' ? 'estimated' : 'unavailable')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const track = await source.track(id, plan?.binding.planId, signal); check()
|
|
103
|
+
if (!track) { clear(); setPlayer(undefined); setStatus('missing'); throw new Error('production-track-unavailable') }
|
|
104
|
+
if (binding && (!track.binding || !sameProductionBinding(track.binding, binding))) throw new Error('production-binding-stale')
|
|
105
|
+
// An estimate remains a caption fallback for ordinary readers; explicit production loads inspect its plan.
|
|
106
|
+
if (!binding && !exporting && track.id === SCRIPT_TRACK && track.utterances.length > 0 && track.utterances.every(utterance => Boolean(utterance.unsaid)) && audioSegments(track).length === 0) { play(undefined); return }
|
|
107
|
+
const position = viewer.position.getSnapshot()
|
|
108
|
+
const at = exporting ? 0 : timedEvents(track).find(event => event.type === 'go' && event.data.section === position.section && Number(event.data.step ?? 0) === position.step)?.t ?? 0
|
|
109
|
+
await start(track, source, operations, load, at, plan, signal, exporting ? undefined : position); check()
|
|
110
|
+
}
|
|
111
|
+
let selecting = false
|
|
112
|
+
const select = () => {
|
|
113
|
+
if (selecting) return
|
|
114
|
+
const request = generation.current + 1
|
|
115
|
+
void loadTrack(playing.getSnapshot()).catch(error => { if (generation.current === request) { setFailure(errorMessage(error)); setStatus('failed') } })
|
|
116
|
+
}
|
|
117
|
+
const stopSelect = playing.subscribe(select)
|
|
118
|
+
const stopLoader = production?.register({ current: currentBinding, async load(binding, signal) {
|
|
119
|
+
try {
|
|
120
|
+
await loadTrack(SCRIPT_TRACK, binding, signal)
|
|
121
|
+
selecting = true; play(SCRIPT_TRACK); selecting = false
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (!signal.aborted && currentBinding(binding)) { setFailure(errorMessage(error)); setStatus('failed') }
|
|
124
|
+
throw error
|
|
125
|
+
}
|
|
126
|
+
} })
|
|
127
|
+
select()
|
|
128
|
+
return () => { generation.current++; stopSelect(); stopLoader?.(); clear() }
|
|
129
|
+
}, [operations, doc, stepClock, playback, server.available, production])
|
|
130
|
+
// Cancel permission synchronously when IR changes, before a late parent grant can run.
|
|
131
|
+
useEffect(() => viewer.doc.subscribe(() => current.current?.pause()), [viewer])
|
|
132
|
+
useEffect(() => () => { play(undefined) }, [])
|
|
133
|
+
const clockError = useStore<Error | undefined>(player?.clock.error, undefined)
|
|
134
|
+
function reloadTrack(track: LectureTrack): void {
|
|
135
|
+
if (!operations || track.id !== playing.getSnapshot()) return
|
|
136
|
+
void start(track, lectureSource(server, doc.config.presmium[LECTURE_ID]), operations, ++generation.current, current.current?.time.getSnapshot() ?? 0).catch(error => setFailure(errorMessage(error)))
|
|
137
|
+
}
|
|
138
|
+
const clockFailure = clockError?.message === 'playback-timeout' ? t.player.playbackTimeout : clockError?.message
|
|
139
|
+
return { player, status, failure: failure ?? clockFailure, language, setLanguage, reloadTrack }
|
|
140
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { Operations, PlaybackControl, PlaybackLease } from '@lincyaw/host'
|
|
2
|
+
import type { IRDocument } from '@lincyaw/kernel'
|
|
3
|
+
import { createStore } from '@lincyaw/host'
|
|
4
|
+
import { eventDrift, lastIndexAt, timedEvents, trackDuration } from '../domain/index.ts'
|
|
5
|
+
import type { LectureTrack, TimedEvent } from '../domain/index.ts'
|
|
6
|
+
import type { TrackClock } from './clock.ts'
|
|
7
|
+
|
|
8
|
+
export class LecturePlayer {
|
|
9
|
+
readonly time = createStore(0)
|
|
10
|
+
/** The time since the lecture reached its current step (ms); undefined while the viewer tries the page out. */
|
|
11
|
+
readonly stepTime = createStore<number | undefined>(0)
|
|
12
|
+
readonly trying = createStore(false)
|
|
13
|
+
readonly waitingFor = createStore<string | undefined>(undefined)
|
|
14
|
+
readonly cursor = createStore<{ sectionId: string, step: number } | undefined>(undefined)
|
|
15
|
+
/** Event-identity selection can expose an empty interval while paused; time seeking and playback cannot. */
|
|
16
|
+
readonly positionMode = createStore<'time' | 'step'>('time')
|
|
17
|
+
readonly duration: number
|
|
18
|
+
readonly events: TimedEvent[]
|
|
19
|
+
readonly dropped: number
|
|
20
|
+
|
|
21
|
+
readonly track: LectureTrack
|
|
22
|
+
readonly clock: TrackClock
|
|
23
|
+
private readonly operations: Operations
|
|
24
|
+
// When the lecture reached the position of each event: a `go` that only changes the view (recorded
|
|
25
|
+
// as the presenter switched views) keeps the step's time, as the page's own clock does.
|
|
26
|
+
private readonly reached: number[]
|
|
27
|
+
private applied = -1
|
|
28
|
+
private frame = 0
|
|
29
|
+
private readonly unsubscribe: () => void
|
|
30
|
+
private readonly playback?: PlaybackLease
|
|
31
|
+
private disposed = false
|
|
32
|
+
private generation = 0
|
|
33
|
+
private readonly bufferMissing: boolean
|
|
34
|
+
|
|
35
|
+
constructor(track: LectureTrack, doc: IRDocument, operations: Operations, clock: TrackClock, playback?: PlaybackControl, bufferMissing = false) {
|
|
36
|
+
this.track = track
|
|
37
|
+
this.clock = clock
|
|
38
|
+
this.operations = operations
|
|
39
|
+
this.bufferMissing = bufferMissing
|
|
40
|
+
const drift = eventDrift(track, doc)
|
|
41
|
+
const kept = timedEvents(track).filter(event => drift[event.index]?.grade !== 'lost')
|
|
42
|
+
this.dropped = track.events.length - kept.length
|
|
43
|
+
this.events = kept.map((event) => {
|
|
44
|
+
const data = drift[event.index]?.data ?? event.data
|
|
45
|
+
if (event.type !== 'go') return { ...event, data }
|
|
46
|
+
const { view: _view, ...position } = data
|
|
47
|
+
return { ...event, data: position }
|
|
48
|
+
})
|
|
49
|
+
let position: string | undefined
|
|
50
|
+
let since = 0
|
|
51
|
+
this.reached = this.events.map((event) => {
|
|
52
|
+
const at = event.type === 'go' ? `${String(event.data.section)}\u0000${String(event.data.step)}` : position
|
|
53
|
+
if (at !== position) [position, since] = [at, event.t]
|
|
54
|
+
return since
|
|
55
|
+
})
|
|
56
|
+
this.duration = Math.max(trackDuration(track), this.events.at(-1)?.t ?? 0)
|
|
57
|
+
this.unsubscribe = operations.subscribe(operation => {
|
|
58
|
+
if (operation.type === 'go' && typeof operation.data.section === 'string') {
|
|
59
|
+
const step = Number(operation.data.step ?? 0)
|
|
60
|
+
if (this.seekStep(operation.data.section, step)) return
|
|
61
|
+
}
|
|
62
|
+
this.tryIt()
|
|
63
|
+
})
|
|
64
|
+
this.playback = playback?.register(() => this.pause())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get playing(): boolean {
|
|
68
|
+
return this.clock.playing.getSnapshot()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async play(): Promise<boolean> {
|
|
72
|
+
if (this.disposed) return false
|
|
73
|
+
if (this.playing) return true
|
|
74
|
+
const generation = ++this.generation
|
|
75
|
+
try {
|
|
76
|
+
if (this.playback && !await this.playback.requestStart()) return false
|
|
77
|
+
if (this.disposed || generation !== this.generation) return false
|
|
78
|
+
const at = this.clock.now()
|
|
79
|
+
if (this.trying.getSnapshot() || at >= this.duration) {
|
|
80
|
+
// Restore the timeline without cancelling the permission just granted
|
|
81
|
+
// for this start. Explicit Back/Seek still pause through their own API.
|
|
82
|
+
this.trying.set(false)
|
|
83
|
+
this.clock.seek(at >= this.duration ? 0 : at)
|
|
84
|
+
this.fold(this.clock.now())
|
|
85
|
+
}
|
|
86
|
+
if (this.disposed || generation !== this.generation) return false
|
|
87
|
+
this.positionMode.set('time')
|
|
88
|
+
this.clock.error.set(undefined)
|
|
89
|
+
if (this.bufferAt(this.clock.now(), this.clock.now())) return true
|
|
90
|
+
this.clock.play()
|
|
91
|
+
this.loop(generation)
|
|
92
|
+
return this.clock.playing.getSnapshot()
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!this.disposed && generation === this.generation) { this.pause(); this.clock.error.set(error instanceof Error ? error : new Error(String(error))) }
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pause(): void {
|
|
100
|
+
this.waitingFor.set(undefined)
|
|
101
|
+
this.generation++
|
|
102
|
+
this.playback?.cancel()
|
|
103
|
+
cancelAnimationFrame(this.frame)
|
|
104
|
+
this.frame = 0
|
|
105
|
+
this.clock.pause()
|
|
106
|
+
this.setTime(this.clock.now())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
seek(t: number): void {
|
|
110
|
+
this.pause()
|
|
111
|
+
this.positionMode.set('time')
|
|
112
|
+
this.trying.set(false)
|
|
113
|
+
this.clock.seek(t)
|
|
114
|
+
this.fold(this.clock.now())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A step is an event boundary, not just a timestamp: zero-length neighbours stay individually selectable. */
|
|
118
|
+
seekStep(sectionId: string, step: number): boolean {
|
|
119
|
+
if (this.disposed) return false
|
|
120
|
+
const target = this.events.findIndex(event => event.type === 'go' && event.data.section === sectionId && Number(event.data.step ?? 0) === step)
|
|
121
|
+
if (target < 0) return false
|
|
122
|
+
this.pause()
|
|
123
|
+
this.trying.set(false)
|
|
124
|
+
const event = this.events[target]!
|
|
125
|
+
this.clock.seek(event.t)
|
|
126
|
+
this.fold(event.t, target)
|
|
127
|
+
this.positionMode.set('step')
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setRate(rate: number): void {
|
|
132
|
+
this.clock.setRate(rate)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
tryIt(): void {
|
|
136
|
+
this.pause()
|
|
137
|
+
if (this.trying.getSnapshot()) return
|
|
138
|
+
this.trying.set(true)
|
|
139
|
+
this.stepTime.set(undefined)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
back(): void {
|
|
143
|
+
this.seek(this.clock.now())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
dispose(): void {
|
|
147
|
+
this.disposed = true
|
|
148
|
+
this.playback?.dispose()
|
|
149
|
+
cancelAnimationFrame(this.frame)
|
|
150
|
+
this.unsubscribe()
|
|
151
|
+
this.clock.dispose()
|
|
152
|
+
this.operations.clear()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fold(t: number, boundary?: number): void {
|
|
156
|
+
this.operations.clear()
|
|
157
|
+
this.cursor.set(undefined)
|
|
158
|
+
const last = boundary ?? Math.max(lastIndexAt(this.events, t), this.events.findIndex(e => e.type === 'go'))
|
|
159
|
+
for (let i = 0; i <= last; i++) this.apply(this.events[i]!, i < last)
|
|
160
|
+
this.applied = last
|
|
161
|
+
this.setTime(t)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private setTime(t: number): void {
|
|
165
|
+
this.time.set(t)
|
|
166
|
+
this.stepTime.set(this.trying.getSnapshot() ? undefined : t - (this.reached[this.applied] ?? 0))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private apply(event: TimedEvent, seeking: boolean): void {
|
|
170
|
+
this.operations.replay({ type: event.type, data: event.data }, seeking)
|
|
171
|
+
if (event.type === 'go' && typeof event.data.section === 'string') this.cursor.set({ sectionId: event.data.section, step: Number(event.data.step ?? 0) })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
advanceTo(t: number): void {
|
|
175
|
+
this.positionMode.set('time')
|
|
176
|
+
while (this.applied + 1 < this.events.length && this.events[this.applied + 1]!.t <= t) this.apply(this.events[++this.applied]!, false)
|
|
177
|
+
this.setTime(t)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private loop(generation: number): void {
|
|
181
|
+
cancelAnimationFrame(this.frame)
|
|
182
|
+
const step = () => {
|
|
183
|
+
if (generation !== this.generation || !this.clock.playing.getSnapshot()) { this.frame = 0; return }
|
|
184
|
+
const before = this.time.getSnapshot(), now = this.clock.now()
|
|
185
|
+
if (this.bufferAt(before, now)) { this.frame = 0; return }
|
|
186
|
+
this.clock.tick()
|
|
187
|
+
this.advanceTo(this.clock.now())
|
|
188
|
+
this.frame = generation === this.generation && this.clock.playing.getSnapshot() ? requestAnimationFrame(step) : 0
|
|
189
|
+
}
|
|
190
|
+
this.frame = requestAnimationFrame(step)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Never let an estimated missing interval run silently in the author playback. */
|
|
194
|
+
private bufferAt(from: number, to: number): boolean {
|
|
195
|
+
if (!this.bufferMissing) return false
|
|
196
|
+
const missing = this.track.utterances.find(unit => unit.unsaid && unit.t + unit.d > from && unit.t <= to)
|
|
197
|
+
if (!missing) return false
|
|
198
|
+
const at = Math.max(from, missing.t)
|
|
199
|
+
this.pause(); this.clock.seek(at); this.advanceTo(at); this.waitingFor.set(missing.id)
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { OPERATIONS, PRODUCTION_PLAYBACK } from '@lincyaw/host'
|
|
2
|
+
import type { PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
|
|
3
|
+
import { version } from '../../package.json'
|
|
4
|
+
import { LECTURE_ID } from '../domain/index.ts'
|
|
5
|
+
import { applyInk, applyPoint, clearInks, clearPointer } from './pointer-ink.ts'
|
|
6
|
+
import { readLecturesFlag } from './state.ts'
|
|
7
|
+
import { ProductionPlaybackController } from './production-playback.ts'
|
|
8
|
+
|
|
9
|
+
let unregister: Unsubscribe[] = []
|
|
10
|
+
|
|
11
|
+
const plugin: PluginDefinition = {
|
|
12
|
+
// dispose releases everything setup made; a paused preview starts the same definition again.
|
|
13
|
+
restartable: true,
|
|
14
|
+
manifest: { id: LECTURE_ID, name: { 'zh-CN': '讲授', en: 'Lecture' }, version, kernel: '^1.0.0', provides: [PRODUCTION_PLAYBACK], requires: [{ token: OPERATIONS, optional: true }] },
|
|
15
|
+
setup(ctx) {
|
|
16
|
+
readLecturesFlag(ctx.config)
|
|
17
|
+
const operations = ctx.tryGet(OPERATIONS)
|
|
18
|
+
unregister = operations
|
|
19
|
+
? [operations.onReplay('point', { apply: applyPoint, clear: clearPointer }), operations.onReplay('ink', { apply: applyInk, clear: clearInks })]
|
|
20
|
+
: []
|
|
21
|
+
const production = new ProductionPlaybackController()
|
|
22
|
+
ctx.provide(PRODUCTION_PLAYBACK, production)
|
|
23
|
+
unregister.push(() => production.dispose())
|
|
24
|
+
},
|
|
25
|
+
dispose() {
|
|
26
|
+
unregister.forEach(off => off())
|
|
27
|
+
unregister = []
|
|
28
|
+
clearPointer()
|
|
29
|
+
clearInks()
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default plugin
|