@banou/media-player 0.10.0 → 0.10.1
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/README.md +33 -4
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/playback.d.ts +17 -0
- package/dist/{engine-YeMWShCv.js → engine-b5_5HscR.js} +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +625 -331
- package/dist/react/components/skip-chapter.d.ts +10 -0
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +17 -1
- package/dist/react/video-player.d.ts +13 -0
- package/dist/utils/chapters.d.ts +37 -0
- package/package.json +1 -1
- package/src/lib/engine/index.ts +1 -1
- package/src/lib/engine/playback.ts +15 -0
- package/src/lib/index.tsx +1 -0
- package/src/lib/react/components/chrome.tsx +2 -0
- package/src/lib/react/components/progress-bar.tsx +127 -25
- package/src/lib/react/components/skip-chapter.tsx +166 -0
- package/src/lib/react/hooks/use-playback.ts +17 -2
- package/src/lib/react/source-feature.ts +10 -1
- package/src/lib/react/video-player.tsx +28 -1
- package/src/lib/utils/chapters.ts +302 -0
- package/src/lib/utils/source.ts +12 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
+
import type { MediaChapter } from '../engine'
|
|
3
4
|
import type { DownloadedRange } from './source-feature'
|
|
4
5
|
import type { DelegatedTracks, ExternalThumbnails, PlayerMedia } from './media'
|
|
5
6
|
import type { ExposePlayerOptions } from '../remote'
|
|
@@ -45,6 +46,19 @@ type CommonOptions = {
|
|
|
45
46
|
title?: string
|
|
46
47
|
autoplay?: boolean
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Named spans of the timeline, drawn as segments on the seekbar.
|
|
51
|
+
*
|
|
52
|
+
* Common to both arms rather than remote-only, unlike `thumbnails`. A local file is just as likely
|
|
53
|
+
* to declare none (mp4 and webm routinely carry no chapters at all), and a caller often knows
|
|
54
|
+
* chapters the container does not: skip-intro ranges from a metadata API are the usual case. Given
|
|
55
|
+
* here they WIN over whatever the container declared, and they paint on the first frame rather than
|
|
56
|
+
* waiting for the pipeline to boot.
|
|
57
|
+
*
|
|
58
|
+
* Expected in seconds, ordered by start and non-overlapping. They need not cover the duration.
|
|
59
|
+
*/
|
|
60
|
+
chapters?: MediaChapter[]
|
|
61
|
+
|
|
48
62
|
/**
|
|
49
63
|
* Draw the control bar. Defaults to true.
|
|
50
64
|
*
|
|
@@ -183,7 +197,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
183
197
|
const local = remote ? null : options as MediaPlayerLocalOptions
|
|
184
198
|
// `title` is common to both arms, so it is read off `options`. Everything else here belongs to the
|
|
185
199
|
// local arm and is absent when the media is remote.
|
|
186
|
-
const { title } = options
|
|
200
|
+
const { title, chapters } = options
|
|
187
201
|
const {
|
|
188
202
|
size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled,
|
|
189
203
|
} = local ?? ({} as Partial<MediaPlayerLocalOptions>)
|
|
@@ -301,6 +315,19 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
301
315
|
})
|
|
302
316
|
}, [setSourceState, audio])
|
|
303
317
|
|
|
318
|
+
/*
|
|
319
|
+
* Guarded, and its own effect, for the reason the ones above are.
|
|
320
|
+
*
|
|
321
|
+
* Folded into the effect that publishes `title` and `size` it would write `chapters: undefined`
|
|
322
|
+
* every time one of those changed, erasing the list libav had already found. The other half of the
|
|
323
|
+
* precedence lives at the engine's publish site, which prefers this prop when it has one: between
|
|
324
|
+
* them the two writers land on the same value whichever runs last.
|
|
325
|
+
*/
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (!chapters) return
|
|
328
|
+
setSourceState({ chapters })
|
|
329
|
+
}, [setSourceState, chapters])
|
|
330
|
+
|
|
304
331
|
return (
|
|
305
332
|
<Chrome
|
|
306
333
|
ref={setContainer}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { MediaChapter } from '../engine'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a chaptered seekbar is broken, and what to paint in each piece.
|
|
5
|
+
*
|
|
6
|
+
* Kept out of the component because the geometry is the whole specification of the feature and is
|
|
7
|
+
* worth testing directly: everything else about chapters on the bar is styling around these two
|
|
8
|
+
* functions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Width of the break drawn between two chapters, in px. */
|
|
12
|
+
const CHAPTER_GAP_PX = 2
|
|
13
|
+
/**
|
|
14
|
+
* A boundary this close to either end of the bar is dropped rather than drawn.
|
|
15
|
+
*
|
|
16
|
+
* Containers routinely declare a last chapter ending a few milliseconds before the file does, so
|
|
17
|
+
* drawing every boundary would cut a hairline segment off the end that reads as a rendering fault.
|
|
18
|
+
* 0.5% of a 20 second file is 100ms, of a two hour file 36 seconds.
|
|
19
|
+
*/
|
|
20
|
+
const EDGE_FRACTION = 0.005
|
|
21
|
+
|
|
22
|
+
const OPAQUE = '#000'
|
|
23
|
+
const CLEAR = '#0000'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the bar is broken, as percentages, including the two ends: [0, ...breaks, 100].
|
|
27
|
+
*
|
|
28
|
+
* Both edges of every chapter count, so chapters that leave un-named time between them break the
|
|
29
|
+
* bar on each side of the gap and the un-named span becomes a segment of its own. Returns nothing
|
|
30
|
+
* when there is no break worth drawing, which is what leaves a file with no chapters, or one whose
|
|
31
|
+
* single chapter spans the whole picture, rendering exactly as it did before chapters existed.
|
|
32
|
+
*/
|
|
33
|
+
export const segmentBounds = (chapters: MediaChapter[], duration: number): number[] => {
|
|
34
|
+
if (!duration) return []
|
|
35
|
+
const breaks = new Set<number>()
|
|
36
|
+
for (const { start, end } of chapters) {
|
|
37
|
+
for (const seconds of [start, end]) {
|
|
38
|
+
const fraction = seconds / duration
|
|
39
|
+
if (fraction <= EDGE_FRACTION || fraction >= 1 - EDGE_FRACTION) continue
|
|
40
|
+
// rounded so two chapters meeting at the same instant cannot produce two boundaries a
|
|
41
|
+
// billionth apart, which would draw a double gap
|
|
42
|
+
breaks.add(Number((fraction * 100).toFixed(4)))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!breaks.size) return []
|
|
46
|
+
return [0, ...[...breaks].sort((a, b) => a - b), 100]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A mask painting the segments `keep` accepts, with a gap at every boundary between segments.
|
|
51
|
+
*
|
|
52
|
+
* The gap is cut from both sides of a boundary so it stays centred on it, and never from the two
|
|
53
|
+
* outer edges, where it would shorten the bar rather than divide it.
|
|
54
|
+
*/
|
|
55
|
+
export const segmentMask = (bounds: number[], keep: (index: number) => boolean): string => {
|
|
56
|
+
const half = CHAPTER_GAP_PX / 2
|
|
57
|
+
const last = bounds.length - 2
|
|
58
|
+
const stops: string[] = []
|
|
59
|
+
for (let i = 0; i <= last; i += 1) {
|
|
60
|
+
const from = bounds[i]!
|
|
61
|
+
const to = bounds[i + 1]!
|
|
62
|
+
const left = i === 0 ? '0%' : `calc(${from}% + ${half}px)`
|
|
63
|
+
const right = i === last ? '100%' : `calc(${to}% - ${half}px)`
|
|
64
|
+
stops.push(`${keep(i) ? OPAQUE : CLEAR} ${left} ${right}`)
|
|
65
|
+
if (i !== last) stops.push(`${CLEAR} calc(${to}% - ${half}px) calc(${to}% + ${half}px)`)
|
|
66
|
+
}
|
|
67
|
+
return `linear-gradient(90deg, ${stops.join(', ')})`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a chapter is, when its title says plainly enough to offer a skip.
|
|
72
|
+
*
|
|
73
|
+
* Only ever a suggestion: the player shows a button for a few seconds and does nothing unless it is
|
|
74
|
+
* pressed. That is what lets this be generous rather than careful. A chapter wrongly called an
|
|
75
|
+
* opening costs a button nobody presses, while an opening this fails to recognise costs the feature.
|
|
76
|
+
*
|
|
77
|
+
* Every rule below was checked against 192 real files (891 chapter markers, 79 distinct chapter
|
|
78
|
+
* sequences) rather than guessed, and the counts quoted are from that sample.
|
|
79
|
+
*/
|
|
80
|
+
export type ChapterKind = 'opening' | 'ending'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Labels that name the thing outright.
|
|
84
|
+
*
|
|
85
|
+
* "Credits" is here rather than among the hedged words because the sample settles it: 48 markers,
|
|
86
|
+
* a median length of 90.0s, and the last chapter of its file every time.
|
|
87
|
+
*/
|
|
88
|
+
const OPENING_LABELS = new Set([
|
|
89
|
+
'op', 'ops', 'opening', 'openings', 'ncop',
|
|
90
|
+
'オープニング', 'op主題歌',
|
|
91
|
+
])
|
|
92
|
+
const ENDING_LABELS = new Set([
|
|
93
|
+
'ed', 'eds', 'ending', 'endings', 'nced', 'credits', 'endcard', 'endroll',
|
|
94
|
+
'エンディング', 'エンドカード', 'エンドロール', 'ed主題歌',
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Used for BOTH the theme and the scene before it, so accepted only when the file has not already
|
|
99
|
+
* named a plain one of that kind.
|
|
100
|
+
*
|
|
101
|
+
* "Intro" is the case that matters: 64 markers in the sample. Where the file also carries an OP or
|
|
102
|
+
* Opening it runs 62.9s to 240s and is a cold open; where it does not, it runs 3s to 91s and is the
|
|
103
|
+
* theme. Deferring to the plain marker separates the two without having to guess from length.
|
|
104
|
+
*/
|
|
105
|
+
const HEDGED_OPENING = new Set(['intro', 'イントロ'])
|
|
106
|
+
/**
|
|
107
|
+
* "Outro" hedges for the same reason, found the same way: the one file in the sample that uses it
|
|
108
|
+
* reads Episode, Credits (90.1s), Outro (56s), Preview. The 90s Credits is the theme and the Outro
|
|
109
|
+
* is the scene after it, so a plain marker in the same file has to win.
|
|
110
|
+
*
|
|
111
|
+
* "Epilogue" is deliberately absent. It is never the theme in the sample (4s in eight files, then
|
|
112
|
+
* 16s, 56s and 122s), so it is a post-ending scene rather than a hedged name for one.
|
|
113
|
+
*/
|
|
114
|
+
const HEDGED_ENDING = new Set(['outro', 'アウトロ'])
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whole labels decided outright, before any word is reduced away.
|
|
118
|
+
*
|
|
119
|
+
* "Credits Start" and "Credits End" are one release's way of bracketing the ending, and they are not
|
|
120
|
+
* interchangeable: the first IS the theme (19 markers, median 89.0s), the second is the 28s tail
|
|
121
|
+
* after it. Reduced word by word the two would come out identical, so they are read whole.
|
|
122
|
+
*/
|
|
123
|
+
const EXACT: Record<string, ChapterKind | null> = {
|
|
124
|
+
'credits start': 'ending',
|
|
125
|
+
'credits end': null,
|
|
126
|
+
'preview end': null,
|
|
127
|
+
'end credits': 'ending',
|
|
128
|
+
'closing credits': 'ending',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Carried alongside a label without changing what it names. */
|
|
132
|
+
const MODIFIERS = new Set([
|
|
133
|
+
'theme', 'themes', 'song', 'sequence',
|
|
134
|
+
'nc', 'non', 'credit', 'creditless', 'textless', 'clean',
|
|
135
|
+
'tv', 'size', 'version', 'ver', 'full', 'the',
|
|
136
|
+
'主題歌', 'ノンクレジット',
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Words that turn a label into something that comes AFTER the thing, not the thing.
|
|
141
|
+
*
|
|
142
|
+
* "Post-Credits" is a real chapter in the sample, sitting between the ED and the preview. Reading
|
|
143
|
+
* only its last segment it is indistinguishable from "Credits", which is why the segments before a
|
|
144
|
+
* match get a look too.
|
|
145
|
+
*/
|
|
146
|
+
const NEGATORS = new Set(['post', 'pre', 'after', 'before', 'non'])
|
|
147
|
+
|
|
148
|
+
/** Everything before the first separator: releases write "OP - Song Name" and "Opening: Title". */
|
|
149
|
+
const LABEL_SEPARATORS = /[-:|~/\\[\]()「」『』【】,.!?"'’]|\s+by\s+/u
|
|
150
|
+
|
|
151
|
+
/** Titles a container writes when it has nothing to say, which must never look like a marker. */
|
|
152
|
+
const isPlaceholder = (title: string): boolean =>
|
|
153
|
+
// "Chapter 07", and the timestamps one muxer writes as titles, which are 300 markers in the sample
|
|
154
|
+
/^chapter\s*\d+$/i.test(title.trim()) || /^\d{1,2}:\d{2}:\d{2}([.,]\d+)?$/.test(title.trim())
|
|
155
|
+
|
|
156
|
+
const normalise = (title: string): string =>
|
|
157
|
+
(title.trim().toLowerCase().split(LABEL_SEPARATORS)[0] ?? '').trim().replace(/\s+/gu, ' ')
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The label reduced to the words that name it, or nothing when it does not reduce to one.
|
|
161
|
+
*
|
|
162
|
+
* Numbers go, so OP2 and "Opening 2" are both an opening, and decoration goes, so is "Opening Theme".
|
|
163
|
+
* What has to be left is a SINGLE word, and that requirement is the whole guard against a title that
|
|
164
|
+
* merely mentions a theme: "The Ending of Everything" keeps four words and matches nothing.
|
|
165
|
+
*/
|
|
166
|
+
const labelWords = (label: string): string[] =>
|
|
167
|
+
label
|
|
168
|
+
// a trailing number belongs to the label rather than being a word: op1, ed02, opening 3
|
|
169
|
+
.replace(/([a-z-ヿ一-鿿])\s*\d+\s*$/u, '$1')
|
|
170
|
+
.split(/[\s_]+/u)
|
|
171
|
+
.map((word) => word.replace(/^[^\w-ヿ一-鿿]+|[^\w-ヿ一-鿿]+$/gu, ''))
|
|
172
|
+
.filter((word) => word && !/^\d+$/.test(word) && !MODIFIERS.has(word))
|
|
173
|
+
|
|
174
|
+
const ofWord = (word: string, hedged: boolean): ChapterKind | undefined => {
|
|
175
|
+
if (OPENING_LABELS.has(word)) return 'opening'
|
|
176
|
+
if (ENDING_LABELS.has(word)) return 'ending'
|
|
177
|
+
if (!hedged) return undefined
|
|
178
|
+
if (HEDGED_OPENING.has(word)) return 'opening'
|
|
179
|
+
if (HEDGED_ENDING.has(word)) return 'ending'
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const kindOf = (title: string, hedged: boolean): ChapterKind | undefined => {
|
|
184
|
+
if (isPlaceholder(title)) return undefined
|
|
185
|
+
const whole = title.trim().toLowerCase().replace(/\s+/gu, ' ')
|
|
186
|
+
if (whole in EXACT) return EXACT[whole] ?? undefined
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
* The label is usually first, but a release will also hang it off the end: "Song Name (Opening)"
|
|
190
|
+
* and "Song Name - OP" are both real. Both ends are tried, and each has to reduce to one naming
|
|
191
|
+
* word on its own.
|
|
192
|
+
*
|
|
193
|
+
* A SEPARATOR is what makes the second one safe. Without it, reading the last word of a title
|
|
194
|
+
* would skip a real scene called "Proclamation of a meeting opening", which is an actual chapter
|
|
195
|
+
* in a real release. That title has no separator, so it stays one many-word segment and matches
|
|
196
|
+
* nothing.
|
|
197
|
+
*/
|
|
198
|
+
const segments = whole.split(LABEL_SEPARATORS).map((part) => part.trim()).filter(Boolean)
|
|
199
|
+
if (segments.some((segment) => segment.split(/[\s_]+/u).some((word) => NEGATORS.has(word)))) return undefined
|
|
200
|
+
|
|
201
|
+
const ends = segments.length > 1 ? [segments[0]!, segments.at(-1)!] : segments
|
|
202
|
+
for (const segment of ends) {
|
|
203
|
+
const words = labelWords(segment)
|
|
204
|
+
if (words.length !== 1) continue
|
|
205
|
+
const kind = ofWord(words[0]!, hedged)
|
|
206
|
+
if (kind) return kind
|
|
207
|
+
}
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Below this there is nothing worth offering to skip, in seconds.
|
|
213
|
+
*
|
|
214
|
+
* Some releases write a marker rather than a span: one group labels a 3s beat "Intro" and a 2s tail
|
|
215
|
+
* "Credits", and a button that jumps you forward three seconds reads as broken. The sample leaves a
|
|
216
|
+
* clean gap to cut in: every junk marker in it is 5s or shorter, and the shortest real theme is 26s
|
|
217
|
+
* (a shortened credits sequence). Anything in between would do; 15 is the middle of the gap.
|
|
218
|
+
*/
|
|
219
|
+
const SHORTEST_WORTH_SKIPPING = 15
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* How long a theme runs, in seconds, for a file whose chapters carry no usable name.
|
|
223
|
+
*
|
|
224
|
+
* Ninety seconds, give or take three, because the standard really is that tight: the sample gives a
|
|
225
|
+
* median of 89.9s for Opening, 90.0 for OP, 90.0 for ED and 89.5 for Ending.
|
|
226
|
+
*
|
|
227
|
+
* Chosen by sweeping both populations, which want opposite things. Widening it looks better against
|
|
228
|
+
* the files that HAVE titles, where the answer is known: 70 to 110 agreed on 137 segments against
|
|
229
|
+
* 123 here. But those files never reach this rule. On the 53 files that do, a wide band pulls in a
|
|
230
|
+
* neighbouring chapter that happens to be 94s, leaves two candidates in the same half, and abstains:
|
|
231
|
+
* 70 to 110 finds an opening in 44 of them where this finds one in 49.
|
|
232
|
+
*
|
|
233
|
+
* Every band quoted above contradicts a title nowhere. Being narrow is what keeps it that way.
|
|
234
|
+
*/
|
|
235
|
+
const THEME_MIN = 87
|
|
236
|
+
const THEME_MAX = 93
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A guess from shape alone, for a file whose chapters are named by a muxer rather than by a person.
|
|
240
|
+
*
|
|
241
|
+
* 53 files in the sample name every chapter after its own timestamp or call it "Chapter 07", which
|
|
242
|
+
* says nothing at all. 46 of those carry exactly one theme-length chapter in each half of the
|
|
243
|
+
* runtime, which is the whole rule: one candidate in the first half is the opening, one in the
|
|
244
|
+
* second is the ending, and anything less clear than that is left alone.
|
|
245
|
+
*
|
|
246
|
+
* Ambiguity is resolved by abstaining, never by picking. Two candidates in a half means neither is
|
|
247
|
+
* offered, because guessing between them is how a viewer gets thrown out of the episode.
|
|
248
|
+
*/
|
|
249
|
+
const guessFromShape = (chapters: MediaChapter[]): (ChapterKind | undefined)[] => {
|
|
250
|
+
const blank: (ChapterKind | undefined)[] = chapters.map(() => undefined)
|
|
251
|
+
// the last chapter's end stands in for the runtime, which is all these files ever have
|
|
252
|
+
const runtime = chapters.at(-1)?.end ?? 0
|
|
253
|
+
if (!runtime) return blank
|
|
254
|
+
|
|
255
|
+
const candidates = chapters
|
|
256
|
+
.map((chapter, index) => ({ index, start: chapter.start, length: chapter.end - chapter.start }))
|
|
257
|
+
.filter(({ length }) => length >= THEME_MIN && length <= THEME_MAX)
|
|
258
|
+
|
|
259
|
+
const half = runtime / 2
|
|
260
|
+
const first = candidates.filter(({ start }) => start < half)
|
|
261
|
+
const second = candidates.filter(({ start }) => start >= half)
|
|
262
|
+
if (first.length === 1) blank[first[0]!.index] = 'opening'
|
|
263
|
+
if (second.length === 1) blank[second[0]!.index] = 'ending'
|
|
264
|
+
return blank
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* What each chapter is, decided across the whole list rather than one title at a time.
|
|
269
|
+
*
|
|
270
|
+
* The list is what resolves the hedged words. A file whose chapters read Intro, OP, Episode, ED,
|
|
271
|
+
* Preview has already said which one the theme is, so its Intro is left alone; one that reads
|
|
272
|
+
* Episode, Intro, Episode, Credits has not, so its Intro is offered. Both shapes are in the sample,
|
|
273
|
+
* 13 files and 24 files.
|
|
274
|
+
*/
|
|
275
|
+
export const classifyChapters = (chapters: MediaChapter[]): (ChapterKind | undefined)[] => {
|
|
276
|
+
const plain = chapters.map(({ title }) => kindOf(title, false))
|
|
277
|
+
|
|
278
|
+
/*
|
|
279
|
+
* A file that is MOSTLY themes is a creditless bonus disc, where they are the content.
|
|
280
|
+
*
|
|
281
|
+
* Offering to skip an opening on a disc of nothing but openings is the one failure this design
|
|
282
|
+
* cannot shrug off, because the button would invite the viewer past the exact thing they put on.
|
|
283
|
+
*
|
|
284
|
+
* Measured by RUNTIME rather than by how many chapters match. Counting them suppresses an
|
|
285
|
+
* ordinary episode, which is two themes out of three chapters; by runtime that same episode is
|
|
286
|
+
* 13% theme and a creditless disc is all of it.
|
|
287
|
+
*/
|
|
288
|
+
const themed = chapters.reduce((sum, c, i) => sum + (plain[i] ? c.end - c.start : 0), 0)
|
|
289
|
+
const total = chapters.reduce((sum, c) => sum + (c.end - c.start), 0)
|
|
290
|
+
if (total > 0 && themed * 2 > total) return chapters.map(() => undefined)
|
|
291
|
+
|
|
292
|
+
const named = chapters.map((chapter, i) => {
|
|
293
|
+
if (chapter.end - chapter.start < SHORTEST_WORTH_SKIPPING) return undefined
|
|
294
|
+
const certain = plain[i]
|
|
295
|
+
if (certain) return certain
|
|
296
|
+
const guess = kindOf(chapter.title, true)
|
|
297
|
+
return guess && !plain.includes(guess) ? guess : undefined
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// shape is the last resort, and only for a file whose titles said nothing whatsoever
|
|
301
|
+
return named.some(Boolean) ? named : guessFromShape(chapters)
|
|
302
|
+
}
|
package/src/lib/utils/source.ts
CHANGED
|
@@ -24,8 +24,19 @@ const fromUrl = async (
|
|
|
24
24
|
) => {
|
|
25
25
|
const length = _length ?? await probeLength(url, credentials)
|
|
26
26
|
const read = async (offset: number, size: number) => {
|
|
27
|
+
/*
|
|
28
|
+
* A read that starts at or past the end answers empty, the way slicing a Blob does.
|
|
29
|
+
*
|
|
30
|
+
* Without this the range comes out as `bytes=<offset>-<length-1>` with the last byte before the
|
|
31
|
+
* first, which is not a range a server can satisfy: it answers 416 and the read throws, so the
|
|
32
|
+
* demuxer's ordinary walk into EOF surfaces as "Reading the video file failed" on a file that is
|
|
33
|
+
* perfectly fine. The blob arm has always returned empty here, so this is also what makes the two
|
|
34
|
+
* arms of `inputToRemuxerInput` behave the same.
|
|
35
|
+
*/
|
|
36
|
+
const end = Math.min(offset + size, length) - 1
|
|
37
|
+
if (end < offset) return new ArrayBuffer(0)
|
|
27
38
|
const response = await fetch(url, {
|
|
28
|
-
headers: { Range: `bytes=${offset}-${
|
|
39
|
+
headers: { Range: `bytes=${offset}-${end}` },
|
|
29
40
|
credentials,
|
|
30
41
|
})
|
|
31
42
|
if (!response.ok) throw new Error(`The source could not be read: HTTP ${response.status}`)
|