@lincyaw/addon-lecture 1.0.0-rc.10 → 1.0.0-rc.11
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.
|
|
3
|
+
"version": "1.0.0-rc.11",
|
|
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.11",
|
|
45
|
+
"@lincyaw/host": "^1.0.0-rc.11",
|
|
46
|
+
"@lincyaw/kernel": "^1.0.0-rc.11"
|
|
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.
|
|
61
|
-
"@lincyaw/
|
|
62
|
-
"@lincyaw/
|
|
63
|
-
"@lincyaw/
|
|
64
|
-
"@lincyaw/
|
|
60
|
+
"@lincyaw/cli": "1.0.0-rc.11",
|
|
61
|
+
"@lincyaw/compiler": "1.0.0-rc.11",
|
|
62
|
+
"@lincyaw/viewer": "1.0.0-rc.11",
|
|
63
|
+
"@lincyaw/kernel": "1.0.0-rc.11",
|
|
64
|
+
"@lincyaw/host": "1.0.0-rc.11"
|
|
65
65
|
},
|
|
66
66
|
"presmium": {
|
|
67
67
|
"preview": {
|
package/src/client/playback.ts
CHANGED
|
@@ -18,14 +18,25 @@ async function createClock(track: LectureTrack, source: LectureSource, signal?:
|
|
|
18
18
|
const length = trackDuration(track)
|
|
19
19
|
const segments = audioSegments(track)
|
|
20
20
|
if (!segments.length) return new SilentClock(length)
|
|
21
|
+
// Clips cut from one file (a score split per step) share one download.
|
|
22
|
+
const files = new Map<string, Promise<string>>()
|
|
23
|
+
const fileOf = (segment: typeof segments[number]) => {
|
|
24
|
+
const key = segment.sha256 ?? segment.assetId ?? segment.src
|
|
25
|
+
let url = files.get(key)
|
|
26
|
+
if (!url) {
|
|
27
|
+
url = track.binding && source.productionMedia && segment.clipId ? source.productionMedia(track.binding, segment.clipId, signal) : source.media(track.id, segment.src)
|
|
28
|
+
files.set(key, url)
|
|
29
|
+
}
|
|
30
|
+
return url
|
|
31
|
+
}
|
|
21
32
|
const loaded = await Promise.allSettled(segments.map(async (segment): Promise<ClockMedia> => {
|
|
22
33
|
if (track.binding && source.productionMedia && !segment.clipId) throw new Error('production-clip-unavailable')
|
|
23
|
-
const url =
|
|
34
|
+
const url = await fileOf(segment)
|
|
24
35
|
return { url, start: segment.start, duration: segment.duration, sourceStartMs: segment.sourceStartMs, volume: segment.volume, muted: segment.muted }
|
|
25
36
|
}))
|
|
26
37
|
const media = loaded.flatMap(result => result.status === 'fulfilled' ? [result.value] : [])
|
|
27
38
|
const failed = loaded.find(result => result.status === 'rejected')
|
|
28
|
-
if (failed || signal?.aborted) { for (const
|
|
39
|
+
if (failed || signal?.aborted) { for (const url of new Set(media.map(item => item.url))) if (url.startsWith('blob:')) URL.revokeObjectURL(url); if (failed) throw failed.reason; signal?.throwIfAborted() }
|
|
29
40
|
return new MediaClock(length, media)
|
|
30
41
|
}
|
|
31
42
|
function installExportHook(player: LecturePlayer, clock: StepClock | undefined): void {
|
package/src/domain/track.ts
CHANGED
|
@@ -289,6 +289,9 @@ export function trackDuration(track: LectureTrack): number {
|
|
|
289
289
|
export interface AudioSegment {
|
|
290
290
|
clipId?: string
|
|
291
291
|
src: string
|
|
292
|
+
/** The file a clip is cut from, when known: clips of one file share one download. */
|
|
293
|
+
sha256?: string
|
|
294
|
+
assetId?: string
|
|
292
295
|
start: number
|
|
293
296
|
duration: number
|
|
294
297
|
sourceStartMs?: number
|
|
@@ -298,5 +301,5 @@ export interface AudioSegment {
|
|
|
298
301
|
|
|
299
302
|
export function audioSegments(track: LectureTrack): AudioSegment[] {
|
|
300
303
|
const said = track.utterances.filter(u => u.clip).map(u => ({ src: `${CLIPS_DIR}/${u.clip}`, start: u.t, duration: u.d, ...u.clipId ? { clipId: u.clipId } : {} }))
|
|
301
|
-
return [...said, ...(track.audio ?? []).map(a => ({ src: a.src, start: a.start * 1000, duration: a.duration * 1000, ...a.clipId ? { clipId: a.clipId } : {}, ...a.sourceStartMs !== undefined ? { sourceStartMs: a.sourceStartMs } : {}, ...a.volume !== undefined ? { volume: a.volume } : {}, ...a.muted !== undefined ? { muted: a.muted } : {} }))]
|
|
304
|
+
return [...said, ...(track.audio ?? []).map(a => ({ src: a.src, start: a.start * 1000, duration: a.duration * 1000, ...a.clipId ? { clipId: a.clipId } : {}, ...a.sha256 ? { sha256: a.sha256 } : {}, ...a.assetId ? { assetId: a.assetId } : {}, ...a.sourceStartMs !== undefined ? { sourceStartMs: a.sourceStartMs } : {}, ...a.volume !== undefined ? { volume: a.volume } : {}, ...a.muted !== undefined ? { muted: a.muted } : {} }))]
|
|
302
305
|
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import { errorMessage, jsonResponse } from '@lincyaw/kernel'
|
|
2
3
|
import type { ProductionMediaQuery, ProductionPlanQuery, Route } from '@lincyaw/kernel'
|
|
3
4
|
import { productionAsset, productionMedia, productionSnapshot } from '../production.ts'
|
|
4
5
|
import { ProductionAssetRefusal } from '../production-assets.ts'
|
|
5
6
|
import { canonicalSource, VoiceSourceRefusal } from '../voice-source.ts'
|
|
6
|
-
import { serveFile } from './serve-file.ts'
|
|
7
|
+
import { MEDIA_CACHE, notModified, serveFile } from './serve-file.ts'
|
|
7
8
|
import { RouteRefusal } from './served-deck.ts'
|
|
8
9
|
import type { ServedDeck } from './served-deck.ts'
|
|
9
10
|
|
|
10
11
|
function audioBytes(bytes: Uint8Array<ArrayBuffer>, mediaType: string, request: Request): Response {
|
|
11
|
-
const
|
|
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
|
|
12
16
|
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
|
|
13
17
|
if (!range || !range[1] && !range[2]) return new Response(request.method === 'HEAD' ? null : bytes, { headers: { ...headers, 'Content-Length': String(size) } })
|
|
14
18
|
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]))
|
|
@@ -14,16 +14,29 @@ const TYPES: Record<string, string> = {
|
|
|
14
14
|
'.mp3': 'audio/mpeg',
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Media may change while the author works, so the browser keeps it but asks again each time (`no-cache`): an unchanged
|
|
19
|
+
* file answers 304 by its tag, and a reader who plays the same piece again, or several clips of one file, loads it once.
|
|
20
|
+
*/
|
|
21
|
+
export const MEDIA_CACHE = 'private, no-cache'
|
|
22
|
+
|
|
23
|
+
export function notModified(request: Request, etag: string, headers: Record<string, string>): Response | undefined {
|
|
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
|
+
}
|
|
26
|
+
|
|
17
27
|
export function serveFile(deckDir: string, file: string | undefined, request: Request): Response {
|
|
18
28
|
const fd = file === undefined ? undefined : openInDeck(deckDir, file)
|
|
19
29
|
if (fd === undefined) return problemResponse(404, 'no such file')
|
|
20
|
-
const { size } = fstatSync(fd)
|
|
21
|
-
const
|
|
30
|
+
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 }
|
|
22
33
|
const body = (start: number, end: number) => Readable.toWeb(createReadStream('', { fd, start, end })) as ReadableStream<Uint8Array>
|
|
23
34
|
const empty = (response: Response) => {
|
|
24
35
|
closeSync(fd)
|
|
25
36
|
return response
|
|
26
37
|
}
|
|
38
|
+
const unchanged = notModified(request, etag, headers)
|
|
39
|
+
if (unchanged) return empty(unchanged)
|
|
27
40
|
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
|
|
28
41
|
if (!range || (!range[1] && !range[2])) {
|
|
29
42
|
if (size === 0 || request.method === 'HEAD') return empty(new Response(null, { headers: { ...headers, 'Content-Length': String(size) } }))
|