@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 +9 -9
- package/src/client/PlayerPanel.css +1 -20
- package/src/client/PlayerPanel.tsx +8 -3
- package/src/client/clock.ts +4 -1
- 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/
|
|
45
|
-
"@lincyaw/
|
|
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/
|
|
62
|
-
"@lincyaw/
|
|
63
|
-
"@lincyaw/viewer": "1.0.0-rc.
|
|
64
|
-
"@lincyaw/
|
|
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,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()
|
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)
|