@banou/media-player 0.10.0 → 0.10.2
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-DqyZgmTv.js} +173 -134
- 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/engine/thumbnails.ts +135 -19
- 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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { css } from '@emotion/react'
|
|
3
|
+
|
|
4
|
+
import { fonts } from '../../utils/fonts'
|
|
5
|
+
import { classifyChapters } from '../../utils/chapters'
|
|
6
|
+
import { usePlayer } from '../player'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How far AHEAD of the chapter the offer appears, in seconds.
|
|
10
|
+
*
|
|
11
|
+
* It arrives just before the opening rather than during it, so the button is already on screen and
|
|
12
|
+
* readable at the moment the theme starts rather than turning up over it. Waiting until the boundary
|
|
13
|
+
* is crossed puts it a beat late, which is what this exists to fix.
|
|
14
|
+
*/
|
|
15
|
+
const OFFER_LEAD_S = 1
|
|
16
|
+
/**
|
|
17
|
+
* How long the offer then stays on screen.
|
|
18
|
+
*
|
|
19
|
+
* Six, so that with the second of lead above about five of them fall inside the opening itself.
|
|
20
|
+
*
|
|
21
|
+
* Short on purpose. The button is a suggestion drawn from a chapter title, and a title can be wrong,
|
|
22
|
+
* so the cost of a mistake is capped at a few seconds of a button nobody wanted rather than a jump
|
|
23
|
+
* out of the episode. Nothing is ever skipped without a press.
|
|
24
|
+
*/
|
|
25
|
+
const OFFER_MS = 6_000
|
|
26
|
+
/** A jump back by more than this is a seek rather than playback, and re-opens the offer. */
|
|
27
|
+
const SEEK_BACK_S = 1
|
|
28
|
+
|
|
29
|
+
const LABELS = { opening: 'Skip Opening', ending: 'Skip Ending' } as const
|
|
30
|
+
|
|
31
|
+
const style = css`
|
|
32
|
+
position: absolute;
|
|
33
|
+
inset: 0;
|
|
34
|
+
z-index: 2;
|
|
35
|
+
pointer-events: none;
|
|
36
|
+
|
|
37
|
+
button {
|
|
38
|
+
position: absolute;
|
|
39
|
+
/* clear of the control bar, which is 6 to 8px of padding plus a row of 18 to 28px controls and
|
|
40
|
+
the seekbar above them */
|
|
41
|
+
bottom: calc(7 * var(--mp-unit));
|
|
42
|
+
right: calc(2.4 * var(--mp-unit));
|
|
43
|
+
|
|
44
|
+
/* the one thing in this layer that can be pressed */
|
|
45
|
+
pointer-events: auto;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
|
|
48
|
+
${fonts.bMedium.bold}
|
|
49
|
+
color: #fff;
|
|
50
|
+
padding: calc(1 * var(--mp-unit)) calc(1.8 * var(--mp-unit));
|
|
51
|
+
border: 1px solid rgba(255, 255, 255, .55);
|
|
52
|
+
border-radius: calc(.4 * var(--mp-unit));
|
|
53
|
+
background-color: rgba(20, 20, 22, .8);
|
|
54
|
+
box-shadow: 0 0 calc(1 * var(--mp-unit)) rgba(0, 0, 0, .5);
|
|
55
|
+
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transform: translateY(calc(.6 * var(--mp-unit)));
|
|
58
|
+
/* visibility as well as opacity, so a faded button cannot be hovered or pressed */
|
|
59
|
+
visibility: hidden;
|
|
60
|
+
transition: opacity .18s ease, transform .18s ease, visibility .18s;
|
|
61
|
+
|
|
62
|
+
&:hover, &:focus-visible {
|
|
63
|
+
background-color: rgba(255, 255, 255, .92);
|
|
64
|
+
color: #111;
|
|
65
|
+
border-color: transparent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&.show button {
|
|
70
|
+
opacity: 1;
|
|
71
|
+
transform: none;
|
|
72
|
+
visibility: visible;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@media (pointer: coarse) {
|
|
76
|
+
button {
|
|
77
|
+
/* a finger needs a target, and the control row below is already 44px */
|
|
78
|
+
min-height: 44px;
|
|
79
|
+
bottom: calc(9 * var(--mp-unit));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
`
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Offers to jump past an opening or ending, when the chapter under the playhead looks like one.
|
|
86
|
+
*
|
|
87
|
+
* The whole feature is this button plus `classifyChapters`, and it is deliberately the weaker half
|
|
88
|
+
* of the pair: the classifier reads titles written by whoever muxed the file, so it is sometimes
|
|
89
|
+
* going to be wrong. Making the offer expire, and never acting on its own, is what makes being wrong
|
|
90
|
+
* cheap. See the classifier for what it matches and the sample it was measured against.
|
|
91
|
+
*/
|
|
92
|
+
export const SkipChapter = () => {
|
|
93
|
+
const chapters = usePlayer((state) => state.chapters)
|
|
94
|
+
const currentTime = usePlayer((state) => state.currentTime)
|
|
95
|
+
const requestSeek = usePlayer((state) => state.requestSeek)
|
|
96
|
+
const player = usePlayer()
|
|
97
|
+
|
|
98
|
+
const kinds = useMemo(() => classifyChapters(chapters), [chapters])
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The chapter worth offering to skip, from a second before it starts until it ends.
|
|
102
|
+
*
|
|
103
|
+
* The window opens EARLY rather than on the boundary, which is the whole difference between the
|
|
104
|
+
* button being on screen when the theme arrives and turning up on top of it.
|
|
105
|
+
*/
|
|
106
|
+
const skippable = useMemo(() => {
|
|
107
|
+
if (typeof currentTime !== 'number') return undefined
|
|
108
|
+
// only skippable chapters are searched: a second before one starts the playhead is still inside
|
|
109
|
+
// its neighbour, so looking for "the chapter containing the playhead" would find the wrong one
|
|
110
|
+
const index = chapters.findIndex((chapter, i) =>
|
|
111
|
+
kinds[i] !== undefined && chapter.start - OFFER_LEAD_S <= currentTime && currentTime < chapter.end)
|
|
112
|
+
const kind = index >= 0 ? kinds[index] : undefined
|
|
113
|
+
return kind ? { kind, end: chapters[index]!.end } : undefined
|
|
114
|
+
}, [chapters, kinds, currentTime])
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
* A backwards jump re-opens the offer.
|
|
118
|
+
*
|
|
119
|
+
* Without it, seeking back to watch an opening again leaves no way to skip it a second time: the
|
|
120
|
+
* chapter has not changed, so the effect below would not re-run and the offer would stay closed.
|
|
121
|
+
*/
|
|
122
|
+
const lastTime = useRef(0)
|
|
123
|
+
const [seekEpoch, setSeekEpoch] = useState(0)
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const time = typeof currentTime === 'number' ? currentTime : 0
|
|
126
|
+
const previous = lastTime.current
|
|
127
|
+
lastTime.current = time
|
|
128
|
+
if (time < previous - SEEK_BACK_S) setSeekEpoch((n) => n + 1)
|
|
129
|
+
}, [currentTime])
|
|
130
|
+
|
|
131
|
+
const [show, setShow] = useState(false)
|
|
132
|
+
// keyed on which chapter it is, so playing through one offer does not re-open it
|
|
133
|
+
const at = skippable ? `${skippable.kind}@${skippable.end}` : ''
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!at) {
|
|
136
|
+
setShow(false)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
setShow(true)
|
|
140
|
+
const close = setTimeout(() => setShow(false), OFFER_MS)
|
|
141
|
+
return () => clearTimeout(close)
|
|
142
|
+
}, [at, seekEpoch])
|
|
143
|
+
|
|
144
|
+
// the offer closes the moment the playhead leaves, however it left
|
|
145
|
+
useEffect(() => { if (!skippable) setShow(false) }, [skippable])
|
|
146
|
+
|
|
147
|
+
if (!skippable) return null
|
|
148
|
+
|
|
149
|
+
const skip = () => {
|
|
150
|
+
setShow(false)
|
|
151
|
+
// the chrome's own seek, which puts the data in place first: landing on unbuffered ground is
|
|
152
|
+
// what wedges firefox's decoder, and the end of an opening is ground nothing has read yet
|
|
153
|
+
if (requestSeek) requestSeek(skippable.end)
|
|
154
|
+
else player.seek(skippable.end)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div css={style} className={show ? 'show' : ''}>
|
|
159
|
+
<button type='button' className='skip-chapter' onClick={skip}>
|
|
160
|
+
{LABELS[skippable.kind]}
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default SkipChapter
|
|
@@ -73,10 +73,20 @@ export const usePlayback = (
|
|
|
73
73
|
// null, and the effect below returns before touching them in that case.
|
|
74
74
|
const {
|
|
75
75
|
read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
|
|
76
|
-
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
|
|
76
|
+
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false, chapters,
|
|
77
77
|
seekPrepareBudgetMs = SEEK_PREPARE_BUDGET_MS,
|
|
78
78
|
} = options ?? ({} as Partial<MediaPlayerLocalOptions>)
|
|
79
79
|
|
|
80
|
+
/*
|
|
81
|
+
* Through a ref because a caller's array is a new identity every render.
|
|
82
|
+
*
|
|
83
|
+
* In the effect's dependencies it would tear down and restart the whole pipeline on each render.
|
|
84
|
+
* Only its value at publish time matters, and a later change is picked up by the effect in
|
|
85
|
+
* `video-player.tsx` that owns the other half of this precedence.
|
|
86
|
+
*/
|
|
87
|
+
const chaptersRef = useRef(chapters)
|
|
88
|
+
chaptersRef.current = chapters
|
|
89
|
+
|
|
80
90
|
// The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
|
|
81
91
|
// `selectedAudioStream`, which is whatever is playing right now.
|
|
82
92
|
const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
|
|
@@ -335,7 +345,12 @@ export const usePlayback = (
|
|
|
335
345
|
return
|
|
336
346
|
}
|
|
337
347
|
controllerRef.current = controller
|
|
338
|
-
|
|
348
|
+
// a caller's chapters beat the container's, and this is the writer that would otherwise
|
|
349
|
+
// land last and overwrite them
|
|
350
|
+
player.setSourceState({
|
|
351
|
+
indexes: controller.indexes,
|
|
352
|
+
chapters: chaptersRef.current ?? controller.chapters,
|
|
353
|
+
})
|
|
339
354
|
// a track chosen before this pipeline existed has to be re-applied to the new renderer
|
|
340
355
|
const chosen = player.selectedSubtitleTrack
|
|
341
356
|
if (typeof chosen === 'number') controller.selectSubtitleStream(chosen)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine'
|
|
1
|
+
import type { MediaChapter, MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine'
|
|
2
2
|
|
|
3
3
|
import { definePlayerFeature } from '@videojs/core/dom'
|
|
4
4
|
|
|
@@ -77,6 +77,14 @@ export type SourceState = {
|
|
|
77
77
|
|
|
78
78
|
/** Keyframe index of the input, which turns a downloaded byte range into a time range. */
|
|
79
79
|
indexes: MediaIndex[]
|
|
80
|
+
/**
|
|
81
|
+
* Named spans of the timeline, drawn as segments on the seekbar. Empty when the source has none.
|
|
82
|
+
*
|
|
83
|
+
* Ordered by start and non-overlapping, which is what the seekbar assumes. They need not cover the
|
|
84
|
+
* whole duration: the engine passes on whatever the container declared, and a caller-supplied list
|
|
85
|
+
* is whatever the caller knows.
|
|
86
|
+
*/
|
|
87
|
+
chapters: MediaChapter[]
|
|
80
88
|
thumbnails: ThumbnailImage[]
|
|
81
89
|
/**
|
|
82
90
|
* Answers for one time directly, when the source has a storyboard it can index but not enumerate.
|
|
@@ -178,6 +186,7 @@ export type SourceState = {
|
|
|
178
186
|
|
|
179
187
|
const initialState: SourceState = {
|
|
180
188
|
indexes: [],
|
|
189
|
+
chapters: [],
|
|
181
190
|
thumbnails: [],
|
|
182
191
|
requestThumbnail: () => {},
|
|
183
192
|
subtitleTracks: [],
|
|
@@ -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}`)
|