@lincyaw/addon-lecture 1.0.0-rc.13 → 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.13",
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/host": "^1.0.0-rc.13",
45
- "@lincyaw/compiler": "^1.0.0-rc.13",
46
- "@lincyaw/kernel": "^1.0.0-rc.13"
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.13",
61
- "@lincyaw/compiler": "1.0.0-rc.13",
62
- "@lincyaw/kernel": "1.0.0-rc.13",
63
- "@lincyaw/viewer": "1.0.0-rc.13",
64
- "@lincyaw/host": "1.0.0-rc.13"
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,6 +77,8 @@ 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
 
81
83
  /** A part of an audio file on the timeline: from `start` for `duration`, playing the file from `sourceStartMs`. */
82
84
  interface Clip { start: number, duration: number, sourceStartMs: number, volume: number, muted: boolean }
@@ -172,8 +174,9 @@ export class MediaClock extends SilentClock {
172
174
  el.muted = clip.muted
173
175
  // Where the file should be now; the element is moved only when it is elsewhere (a jump, or a clip that does not
174
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.
175
178
  const offset = (clip.sourceStartMs + t - clip.start) / 1000
176
- if (Math.abs(el.currentTime - offset) * 1000 > DRIFT_MS) el.currentTime = offset
179
+ if (Math.abs(el.currentTime - offset) * 1000 > (el.paused ? RESUME_MS : DRIFT_MS)) el.currentTime = offset
177
180
  el.playbackRate = this.rate.getSnapshot()
178
181
  if (el.paused) void el.play().then(() => {
179
182
  if (!this.playing.getSnapshot() || !MediaClock.clipAt(clips, super.now())) el.pause()
@@ -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)