@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/addon-lecture",
3
- "version": "1.0.0-rc.12",
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.12",
45
- "@lincyaw/host": "^1.0.0-rc.12",
46
- "@lincyaw/kernel": "^1.0.0-rc.12"
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/cli": "1.0.0-rc.12",
61
- "@lincyaw/host": "1.0.0-rc.12",
62
- "@lincyaw/compiler": "1.0.0-rc.12",
63
- "@lincyaw/viewer": "1.0.0-rc.12",
64
- "@lincyaw/kernel": "1.0.0-rc.12"
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-start__button {
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, PmIcon, PmIconButton, PmMenu, PmTabs, PRODUCTION_PLAYBACK, TIMED_CAPTIONS, useMessages, useServer, useService, useStore } from '@lincyaw/host'
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"><button type="button" className="pm-lecture-start__button" aria-label={t.player.play} onClick={() => void player.play()}><PmIcon name="play" /></button></div>}
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)}>
@@ -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 media: { el: HTMLAudioElement, start: number, duration: number, sourceStartMs: number }[]
92
+ private readonly voices: { el: HTMLAudioElement, clips: Clip[] }[] = []
83
93
 
84
94
  constructor(duration: number, media: ClockMedia[]) {
85
95
  super(duration)
86
- this.media = media.map(({ url, start, duration: length, sourceStartMs = 0, volume = 1, muted = false }) => {
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
- el.volume = volume
92
- el.muted = muted
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
- return this.media.find(m => t >= m.start && t < m.start + m.duration)
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.media) el.pause()
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 m of this.media) {
130
- if (t >= m.start && t < m.start + m.duration) m.el.currentTime = (m.sourceStartMs + t - m.start) / 1000
131
- else m.el.pause()
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.media) el.playbackRate = rate
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 m of this.media) {
145
- if (!this.playing.getSnapshot() || t < m.start || t >= m.start + m.duration) {
146
- if (!m.el.paused) m.el.pause()
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
- const offset = (m.sourceStartMs + t - m.start) / 1000
150
- if (m.el.paused || Math.abs(m.el.currentTime - offset) * 1000 > DRIFT_MS) m.el.currentTime = offset
151
- m.el.playbackRate = this.rate.getSnapshot()
152
- if (m.el.paused) void m.el.play().then(() => {
153
- const at = super.now()
154
- if (!this.playing.getSnapshot() || at < m.start || at >= m.start + m.duration) m.el.pause()
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.media) {
198
+ for (const { el } of this.voices) {
171
199
  el.pause()
172
200
  el.onerror = null
173
201
  const url = el.src
@@ -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')
@@ -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, readProductionAssetFile } from './production-assets.ts'
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
- export async function productionMedia(served: ServedDeck, query: ProductionMediaQuery, signal?: AbortSignal): Promise<string | { bytes: Uint8Array<ArrayBuffer>, mediaType: string }> {
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
- const bytes = await readProductionAssetFile(file.asset, served.deckDir, signal)
96
+ assertProductionAssetFile(file.asset, served.deckDir)
93
97
  await current(served, query); signal?.throwIfAborted()
94
- return { bytes: new Uint8Array(bytes), mediaType: file.asset.asset.mediaType }
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 { MEDIA_CACHE, notModified, serveFile } from './serve-file.ts'
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 media = await productionMedia(served, query(request, true) as ProductionMediaQuery, request.signal)
52
- return typeof media === 'string' ? serveFile(served.deckDir, media, request) : audioBytes(media.bytes, media.mediaType, request)
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
- export function serveFile(deckDir: string, file: string | undefined, request: Request): Response {
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)