@lincyaw/addon-lecture 1.0.0-rc.12 → 1.0.0-rc.14
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/package.json +9 -9
- package/src/client/PlayerPanel.css +1 -20
- package/src/client/PlayerPanel.tsx +8 -3
- package/src/client/clock.ts +50 -22
- package/src/client/recorder.ts +2 -1
- package/src/node/production.ts +9 -5
- package/src/node/routes/production.ts +3 -17
- package/src/node/routes/serve-file.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/addon-lecture",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.14",
|
|
4
4
|
"description": "Presmium plugin: lecture tracks: recording, playback, scripted lectures with speech, and video export (presmium-lecture)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"playwright-chromium": "^1.40.0",
|
|
42
42
|
"react": "^18.3.1",
|
|
43
43
|
"react-dom": "^18.3.1",
|
|
44
|
-
"@lincyaw/compiler": "^1.0.0-rc.
|
|
45
|
-
"@lincyaw/host": "^1.0.0-rc.
|
|
46
|
-
"@lincyaw/kernel": "^1.0.0-rc.
|
|
44
|
+
"@lincyaw/compiler": "^1.0.0-rc.14",
|
|
45
|
+
"@lincyaw/host": "^1.0.0-rc.14",
|
|
46
|
+
"@lincyaw/kernel": "^1.0.0-rc.14"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"playwright-chromium": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"@types/react": "18.3.28",
|
|
58
58
|
"react-dom": "18.3.1",
|
|
59
59
|
"@types/react-dom": "18.3.7",
|
|
60
|
-
"@lincyaw/
|
|
61
|
-
"@lincyaw/host": "1.0.0-rc.
|
|
62
|
-
"@lincyaw/
|
|
63
|
-
"@lincyaw/viewer": "1.0.0-rc.
|
|
64
|
-
"@lincyaw/kernel": "1.0.0-rc.
|
|
60
|
+
"@lincyaw/compiler": "1.0.0-rc.14",
|
|
61
|
+
"@lincyaw/host": "1.0.0-rc.14",
|
|
62
|
+
"@lincyaw/cli": "1.0.0-rc.14",
|
|
63
|
+
"@lincyaw/viewer": "1.0.0-rc.14",
|
|
64
|
+
"@lincyaw/kernel": "1.0.0-rc.14"
|
|
65
65
|
},
|
|
66
66
|
"presmium": {
|
|
67
67
|
"preview": {
|
|
@@ -129,25 +129,6 @@ html.pm-lecture-exporting .pm-slide-fit__canvas {
|
|
|
129
129
|
pointer-events: none;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
.pm-lecture-
|
|
133
|
-
display: grid;
|
|
134
|
-
place-items: center;
|
|
135
|
-
width: var(--pm-size-start);
|
|
136
|
-
height: var(--pm-size-start);
|
|
137
|
-
border: 0;
|
|
138
|
-
border-radius: var(--pm-radius-pill);
|
|
139
|
-
background: color-mix(in srgb, var(--pm-color-ink) 78%, transparent);
|
|
140
|
-
color: var(--pm-color-bg);
|
|
141
|
-
box-shadow: var(--pm-shadow-float);
|
|
142
|
-
cursor: pointer;
|
|
132
|
+
.pm-lecture-start > * {
|
|
143
133
|
pointer-events: auto;
|
|
144
134
|
}
|
|
145
|
-
|
|
146
|
-
.pm-lecture-start__button:hover {
|
|
147
|
-
background: var(--pm-color-ink);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
.pm-lecture-start__button .pm-ui-icon {
|
|
151
|
-
width: var(--pm-size-start-icon);
|
|
152
|
-
height: var(--pm-size-start-icon);
|
|
153
|
-
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { PmCaption, PmDrawer,
|
|
1
|
+
import { PmCaption, PmDrawer, PmIconButton, PmMenu, PmTabs, PRODUCTION_PLAYBACK, TIMED_CAPTIONS, useMessages, useServer, useService, useStore } from '@lincyaw/host'
|
|
2
2
|
import type { MenuItem } from '@lincyaw/host'
|
|
3
|
-
import { isPreviewEmbedded } from '@lincyaw/kernel'
|
|
3
|
+
import { isPreviewEmbedded, notifyEmbedder } from '@lincyaw/kernel'
|
|
4
4
|
import { useEffect, useMemo, useState } from 'react'
|
|
5
5
|
import type { CSSProperties } from 'react'
|
|
6
6
|
import {
|
|
@@ -63,6 +63,11 @@ export default function PlayerPanel(_props: { view?: string }) {
|
|
|
63
63
|
const time = useStore(player?.time, 0),
|
|
64
64
|
clockPlaying = useStore(player?.clock.playing, false),
|
|
65
65
|
rate = useStore(player?.clock.rate, 1)
|
|
66
|
+
// The page that embeds this one learns the lecture's state: it keeps its cover while loading, offers Play when waiting.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!id) return
|
|
69
|
+
notifyEmbedder({ lecture: failure || status === 'missing' ? 'failed' : blocked ? 'waiting' : !player ? 'loading' : clockPlaying ? 'playing' : 'paused' })
|
|
70
|
+
}, [id, failure, status, blocked, player, clockPlaying])
|
|
66
71
|
const groupedCaptions = useMemo(() => new Map(player?.track.utterances.map(utterance => [utterance.id, captionGroups(utterance)])), [player?.track])
|
|
67
72
|
const cursor = useStore(player?.cursor), positionMode = useStore(player?.positionMode, 'time')
|
|
68
73
|
const [compact, setCompact] = useState(() => matchMedia('(max-width: 479px)').matches)
|
|
@@ -175,7 +180,7 @@ export default function PlayerPanel(_props: { view?: string }) {
|
|
|
175
180
|
else if (option === 'close') play(undefined)
|
|
176
181
|
}
|
|
177
182
|
return <>
|
|
178
|
-
{blocked && <div className="pm-lecture-start"><
|
|
183
|
+
{blocked && <div className="pm-lecture-start"><PmIconButton variant="overlay" icon="play" label={t.player.play} onClick={() => void player.play()} /></div>}
|
|
179
184
|
{(captions || current?.unsaid) && (current || manual) && caption && <PmCaption overlay label={t.player.captions} lines={[caption]} lang={lang}
|
|
180
185
|
data-stale={current && stale.has(current.id) ? 'true' : undefined} hint={current && stale.has(current.id) ? t.player.stale : undefined} />}
|
|
181
186
|
{panel && <PmDrawer open label={panel === 'transcript' ? t.player.transcript : t.player.chapters} onClose={() => setPanel(undefined)}>
|
package/src/client/clock.ts
CHANGED
|
@@ -77,21 +77,35 @@ export interface ClockMedia {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
const DRIFT_MS = 250
|
|
80
|
+
// How far a paused element may be from where it resumes: under a frame, not worth a seek.
|
|
81
|
+
const RESUME_MS = 20
|
|
80
82
|
|
|
83
|
+
/** A part of an audio file on the timeline: from `start` for `duration`, playing the file from `sourceStartMs`. */
|
|
84
|
+
interface Clip { start: number, duration: number, sourceStartMs: number, volume: number, muted: boolean }
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Plays the timeline's audio with one element per file: a clip is where on the timeline a part of its file plays,
|
|
88
|
+
* so clips that continue one another (a score cut at every step) run on in one element, without a pause, a seek or a
|
|
89
|
+
* click at their boundary. A file gets another element only for clips of it that overlap in time.
|
|
90
|
+
*/
|
|
81
91
|
export class MediaClock extends SilentClock {
|
|
82
|
-
private readonly
|
|
92
|
+
private readonly voices: { el: HTMLAudioElement, clips: Clip[] }[] = []
|
|
83
93
|
|
|
84
94
|
constructor(duration: number, media: ClockMedia[]) {
|
|
85
95
|
super(duration)
|
|
86
|
-
|
|
96
|
+
for (const { url, start, duration: length, sourceStartMs = 0, volume = 1, muted = false } of [...media].sort((a, b) => a.start - b.start)) {
|
|
97
|
+
const clip = { start, duration: length, sourceStartMs, volume, muted }
|
|
98
|
+
const free = this.voices.find(voice => voice.el.src === new URL(url, location.href).href && voice.clips.at(-1)!.start + voice.clips.at(-1)!.duration <= start)
|
|
99
|
+
if (free) {
|
|
100
|
+
free.clips.push(clip)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
87
103
|
const el = new Audio()
|
|
88
104
|
el.preload = 'auto'
|
|
89
105
|
el.onerror = () => this.fail(new Error([url, el.error?.message].filter(Boolean).join(': ')))
|
|
90
106
|
el.src = url
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return { el, start, duration: length, sourceStartMs }
|
|
94
|
-
})
|
|
107
|
+
this.voices.push({ el, clips: [clip] })
|
|
108
|
+
}
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
private fail(error: Error): void {
|
|
@@ -99,8 +113,16 @@ export class MediaClock extends SilentClock {
|
|
|
99
113
|
this.error.set(error)
|
|
100
114
|
}
|
|
101
115
|
|
|
116
|
+
private static clipAt(clips: readonly Clip[], t: number): Clip | undefined {
|
|
117
|
+
return clips.find(clip => t >= clip.start && t < clip.start + clip.duration)
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
private mediaAt(t: number) {
|
|
103
|
-
|
|
121
|
+
for (const voice of this.voices) {
|
|
122
|
+
const clip = MediaClock.clipAt(voice.clips, t)
|
|
123
|
+
if (clip) return { el: voice.el, ...clip }
|
|
124
|
+
}
|
|
125
|
+
return undefined
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
override now(): number {
|
|
@@ -121,37 +143,43 @@ export class MediaClock extends SilentClock {
|
|
|
121
143
|
|
|
122
144
|
override pause(): void {
|
|
123
145
|
super.pause()
|
|
124
|
-
for (const { el } of this.
|
|
146
|
+
for (const { el } of this.voices) el.pause()
|
|
125
147
|
}
|
|
126
148
|
|
|
127
149
|
override seek(t: number): void {
|
|
128
150
|
super.seek(t)
|
|
129
|
-
for (const
|
|
130
|
-
|
|
131
|
-
|
|
151
|
+
for (const { el, clips } of this.voices) {
|
|
152
|
+
const clip = MediaClock.clipAt(clips, t)
|
|
153
|
+
if (clip) el.currentTime = (clip.sourceStartMs + t - clip.start) / 1000
|
|
154
|
+
else el.pause()
|
|
132
155
|
}
|
|
133
156
|
this.tick()
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
override setRate(rate: number): void {
|
|
137
160
|
super.setRate(rate)
|
|
138
|
-
for (const { el } of this.
|
|
161
|
+
for (const { el } of this.voices) el.playbackRate = rate
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
override tick(): void {
|
|
142
165
|
super.tick()
|
|
143
166
|
const t = this.now()
|
|
144
|
-
for (const
|
|
145
|
-
|
|
146
|
-
|
|
167
|
+
for (const { el, clips } of this.voices) {
|
|
168
|
+
const clip = this.playing.getSnapshot() ? MediaClock.clipAt(clips, t) : undefined
|
|
169
|
+
if (!clip) {
|
|
170
|
+
if (!el.paused) el.pause()
|
|
147
171
|
continue
|
|
148
172
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
173
|
+
el.volume = clip.volume
|
|
174
|
+
el.muted = clip.muted
|
|
175
|
+
// Where the file should be now; the element is moved only when it is elsewhere (a jump, or a clip that does not
|
|
176
|
+
// continue the last one), never at the boundary between clips that continue each other.
|
|
177
|
+
// A playing element is left alone within DRIFT_MS; a paused one resumes exactly where its clip is.
|
|
178
|
+
const offset = (clip.sourceStartMs + t - clip.start) / 1000
|
|
179
|
+
if (Math.abs(el.currentTime - offset) * 1000 > (el.paused ? RESUME_MS : DRIFT_MS)) el.currentTime = offset
|
|
180
|
+
el.playbackRate = this.rate.getSnapshot()
|
|
181
|
+
if (el.paused) void el.play().then(() => {
|
|
182
|
+
if (!this.playing.getSnapshot() || !MediaClock.clipAt(clips, super.now())) el.pause()
|
|
155
183
|
}).catch((error: unknown) => {
|
|
156
184
|
if (error instanceof DOMException && error.name === 'AbortError') return
|
|
157
185
|
// Autoplay without the reader's gesture: not a failure, playback waits for their click.
|
|
@@ -167,7 +195,7 @@ export class MediaClock extends SilentClock {
|
|
|
167
195
|
|
|
168
196
|
override dispose(): void {
|
|
169
197
|
super.dispose()
|
|
170
|
-
for (const { el } of this.
|
|
198
|
+
for (const { el } of this.voices) {
|
|
171
199
|
el.pause()
|
|
172
200
|
el.onerror = null
|
|
173
201
|
const url = el.src
|
package/src/client/recorder.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { createStore } from '@lincyaw/host'
|
|
|
4
4
|
import { irHash, LECTURE_ID, referencedBlocks, sectionHashes } from '../domain/index.ts'
|
|
5
5
|
import type { MediaSegment, Recording, VideoSegment } from '../domain/index.ts'
|
|
6
6
|
import type { Capture, Chunk } from './media.ts'
|
|
7
|
-
import { captureAudio, captureVideo } from './media.ts'
|
|
8
7
|
import { readAnswer } from './source.ts'
|
|
9
8
|
|
|
10
9
|
const UPLOAD_MS = 5000
|
|
@@ -125,6 +124,8 @@ export class LectureRecorder {
|
|
|
125
124
|
const track = source === 'audio' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0]
|
|
126
125
|
if (!track) continue
|
|
127
126
|
const segment = { source, section, start, pending: [] as Chunk[] } as Omit<Segment, 'capture' | 'src'> as Segment
|
|
127
|
+
// The encoders load with the first recording, not with every presentation.
|
|
128
|
+
const { captureAudio, captureVideo } = await import('./media.ts')
|
|
128
129
|
const capture = await (source === 'audio' ? captureAudio : captureVideo)(track, chunk => segment.pending.push(chunk))
|
|
129
130
|
if (!capture) {
|
|
130
131
|
if (source === 'audio') throw new Error('no audio encoder')
|
package/src/node/production.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { synthesizeLecture } from './synthesize.ts'
|
|
|
10
10
|
import type { SynthesizedClip } from './synthesize.ts'
|
|
11
11
|
import type { ServedDeck } from './routes/served-deck.ts'
|
|
12
12
|
import { RouteRefusal } from './routes/served-deck.ts'
|
|
13
|
-
import { assertProductionAssetFile, inspectProductionAsset
|
|
13
|
+
import { assertProductionAssetFile, inspectProductionAsset } from './production-assets.ts'
|
|
14
14
|
import type { ProductionAssetFile } from './production-assets.ts'
|
|
15
15
|
|
|
16
16
|
interface ClipFile { audio?: ProductionAudio, asset?: ProductionAssetFile, path: string, stamp: string }
|
|
@@ -85,18 +85,22 @@ export async function productionAsset(served: ServedDeck, query: ProductionAsset
|
|
|
85
85
|
return { source: now.source, irRevision: now.revision, asset: inspected.asset }
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The file a clip plays, in the deck. A production asset was read and hashed when the snapshot was made; it is the same
|
|
90
|
+
* file while its stamp is, and its hash tags it, so a reader who has it is answered without reading it again.
|
|
91
|
+
*/
|
|
92
|
+
export async function productionMedia(served: ServedDeck, query: ProductionMediaQuery, signal?: AbortSignal): Promise<{ path: string, etag?: string, mediaType?: string }> {
|
|
89
93
|
const snapshot = await productionSnapshot(served, query, signal), file = snapshot.clips.get(query.clipId)
|
|
90
94
|
if (!file) throw new RouteRefusal(404, 'production-clip-unknown')
|
|
91
95
|
if (file.asset) {
|
|
92
|
-
|
|
96
|
+
assertProductionAssetFile(file.asset, served.deckDir)
|
|
93
97
|
await current(served, query); signal?.throwIfAborted()
|
|
94
|
-
return {
|
|
98
|
+
return { path: file.asset.path, etag: `"${file.asset.asset.sha256}"`, mediaType: file.asset.asset.mediaType }
|
|
95
99
|
}
|
|
96
100
|
const hit = await served.speech().cached?.(query.clipId, signal)
|
|
97
101
|
if (!hit || typeof hit.audio !== 'string' || hit.audio !== file.path || Math.round(hit.duration * 1000) !== file.audio!.durationMs) throw new RouteRefusal(409, 'production-media-changed')
|
|
98
102
|
if (await fileStamp(file.path) !== file.stamp) throw new RouteRefusal(409, 'production-media-changed')
|
|
99
103
|
await current(served, query)
|
|
100
104
|
signal?.throwIfAborted()
|
|
101
|
-
return file.path
|
|
105
|
+
return { path: file.path }
|
|
102
106
|
}
|
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
1
|
import { errorMessage, jsonResponse } from '@lincyaw/kernel'
|
|
3
2
|
import type { ProductionMediaQuery, ProductionPlanQuery, Route } from '@lincyaw/kernel'
|
|
4
3
|
import { productionAsset, productionMedia, productionSnapshot } from '../production.ts'
|
|
5
4
|
import { ProductionAssetRefusal } from '../production-assets.ts'
|
|
6
5
|
import { canonicalSource, VoiceSourceRefusal } from '../voice-source.ts'
|
|
7
|
-
import {
|
|
6
|
+
import { serveFile } from './serve-file.ts'
|
|
8
7
|
import { RouteRefusal } from './served-deck.ts'
|
|
9
8
|
import type { ServedDeck } from './served-deck.ts'
|
|
10
9
|
|
|
11
|
-
function audioBytes(bytes: Uint8Array<ArrayBuffer>, mediaType: string, request: Request): Response {
|
|
12
|
-
const etag = `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 22)}"`
|
|
13
|
-
const size = bytes.byteLength, headers = { 'Content-Type': mediaType, 'Cache-Control': MEDIA_CACHE, 'Accept-Ranges': 'bytes', ETag: etag }
|
|
14
|
-
const unchanged = notModified(request, etag, headers)
|
|
15
|
-
if (unchanged) return unchanged
|
|
16
|
-
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
|
|
17
|
-
if (!range || !range[1] && !range[2]) return new Response(request.method === 'HEAD' ? null : bytes, { headers: { ...headers, 'Content-Length': String(size) } })
|
|
18
|
-
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]))
|
|
19
|
-
const end = range[1] && range[2] ? Math.min(size - 1, Number(range[2])) : size - 1
|
|
20
|
-
if (start >= size || start > end) return new Response(null, { status: 416, headers: { ...headers, 'Content-Range': `bytes */${size}` } })
|
|
21
|
-
return new Response(request.method === 'HEAD' ? null : bytes.subarray(start, end + 1), { status: 206, headers: { ...headers, 'Content-Length': String(end - start + 1), 'Content-Range': `bytes ${start}-${end}/${size}` } })
|
|
22
|
-
}
|
|
23
|
-
|
|
24
10
|
function query(request: Request, media = false): ProductionPlanQuery | ProductionMediaQuery {
|
|
25
11
|
const params = new URL(request.url).searchParams
|
|
26
12
|
let source: unknown
|
|
@@ -48,8 +34,8 @@ export function productionRoutes(served: ServedDeck): Record<string, Route> {
|
|
|
48
34
|
return jsonResponse(await productionAsset(served, { ...query(request), assetId }, request.signal))
|
|
49
35
|
}),
|
|
50
36
|
'production-media': read(async request => {
|
|
51
|
-
const
|
|
52
|
-
return
|
|
37
|
+
const { path, ...known } = await productionMedia(served, query(request, true) as ProductionMediaQuery, request.signal)
|
|
38
|
+
return serveFile(served.deckDir, path, request, known)
|
|
53
39
|
}),
|
|
54
40
|
}
|
|
55
41
|
}
|
|
@@ -24,12 +24,22 @@ export function notModified(request: Request, etag: string, headers: Record<stri
|
|
|
24
24
|
return request.headers.get('if-none-match')?.split(',').some(tag => tag.trim() === etag) ? new Response(null, { status: 304, headers: { ...headers, ETag: etag } }) : undefined
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** What the caller knows of the file: a tag of its content (else its size and time make one) and its media type. */
|
|
28
|
+
export interface ServedFile {
|
|
29
|
+
etag?: string
|
|
30
|
+
mediaType?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function serveFile(deckDir: string, file: string | undefined, request: Request, known: ServedFile = {}): Response {
|
|
34
|
+
if (known.etag) {
|
|
35
|
+
const unchanged = notModified(request, known.etag, { 'Cache-Control': MEDIA_CACHE })
|
|
36
|
+
if (unchanged) return unchanged
|
|
37
|
+
}
|
|
28
38
|
const fd = file === undefined ? undefined : openInDeck(deckDir, file)
|
|
29
39
|
if (fd === undefined) return problemResponse(404, 'no such file')
|
|
30
40
|
const { size, mtimeMs } = fstatSync(fd)
|
|
31
|
-
const etag = `"${size.toString(36)}-${Math.round(mtimeMs).toString(36)}"`
|
|
32
|
-
const headers = { 'Content-Type': TYPES[extname(file!)] ?? 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Cache-Control': MEDIA_CACHE, ETag: etag }
|
|
41
|
+
const etag = known.etag ?? `"${size.toString(36)}-${Math.round(mtimeMs).toString(36)}"`
|
|
42
|
+
const headers = { 'Content-Type': known.mediaType ?? TYPES[extname(file!)] ?? 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Cache-Control': MEDIA_CACHE, ETag: etag }
|
|
33
43
|
const body = (start: number, end: number) => Readable.toWeb(createReadStream('', { fd, start, end })) as ReadableStream<Uint8Array>
|
|
34
44
|
const empty = (response: Response) => {
|
|
35
45
|
closeSync(fd)
|