@omnimedia/omnitool 1.1.0-88 → 1.1.0-89
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 +4 -1
- package/package.json +1 -1
- package/s/timeline/renderers/export/parts/cursor.ts +169 -24
- package/s/timeline/renderers/player/parts/playback.ts +75 -21
- package/s/timeline/renderers/player/player.ts +8 -0
- package/x/demo/demo.bundle.min.js +103 -103
- package/x/demo/demo.bundle.min.js.map +4 -4
- package/x/index.html +2 -2
- package/x/tests.bundle.min.js +107 -107
- package/x/tests.bundle.min.js.map +4 -4
- package/x/tests.html +1 -1
- package/x/timeline/renderers/export/parts/cursor.d.ts +23 -6
- package/x/timeline/renderers/export/parts/cursor.js +135 -13
- package/x/timeline/renderers/export/parts/cursor.js.map +1 -1
- package/x/timeline/renderers/player/parts/playback.d.ts +4 -1
- package/x/timeline/renderers/player/parts/playback.js +63 -16
- package/x/timeline/renderers/player/parts/playback.js.map +1 -1
- package/x/timeline/renderers/player/player.d.ts +2 -0
- package/x/timeline/renderers/player/player.js +6 -0
- package/x/timeline/renderers/player/player.js.map +1 -1
package/README.md
CHANGED
|
@@ -312,11 +312,14 @@ const driver = await Driver.setup({workerUrl})
|
|
|
312
312
|
const player = await omni.playback(timeline)
|
|
313
313
|
|
|
314
314
|
document.body.appendChild(player.canvas)
|
|
315
|
-
player.play()
|
|
315
|
+
await player.play()
|
|
316
|
+
player.playbackRate = 0.5 // slow motion
|
|
317
|
+
player.playbackRate = -1 // reverse
|
|
316
318
|
```
|
|
317
319
|
|
|
318
320
|
Notes:
|
|
319
321
|
- Call `await player.update(timeline)` if you update the timeline.
|
|
322
|
+
- `playbackRate` supports slower, faster, and reverse visual playback. Audio currently plays only at `1`.
|
|
320
323
|
|
|
321
324
|
## 📤 Export
|
|
322
325
|
|
package/package.json
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
|
|
2
|
+
import {ALL_FORMATS, Input, VideoSampleSink} from "mediabunny"
|
|
3
|
+
|
|
2
4
|
import {ms, Ms} from "../../../../units/ms.js"
|
|
3
5
|
import {Driver} from "../../../../driver/driver.js"
|
|
4
6
|
import {TimelineFile} from "../../../parts/basics.js"
|
|
5
7
|
import {DecoderSource} from "../../../../driver/fns/schematic.js"
|
|
8
|
+
import {loadDecoderSource} from "../../../../driver/utils/load-decoder-source.js"
|
|
6
9
|
import {createVisualSampler} from "../../parts/samplers/visual/sampler.js"
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*/
|
|
11
|
+
type StreamCursor<T> = {
|
|
12
|
+
next(target: number): Promise<T | undefined>
|
|
13
|
+
cancel(): Promise<void>
|
|
14
|
+
}
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
16
|
+
type VideoFrameCursor = StreamCursor<VideoFrame>
|
|
17
|
+
|
|
18
|
+
abstract class BaseVisualSampler {
|
|
19
|
+
readonly #videoCursors = new Map<number, VideoFrameCursor>()
|
|
20
|
+
readonly #sampler
|
|
18
21
|
|
|
19
22
|
constructor(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
protected driver: Driver,
|
|
24
|
+
protected resolveMedia: (hash: string) => DecoderSource,
|
|
25
|
+
protected timeline: TimelineFile
|
|
23
26
|
) {
|
|
24
27
|
this.#sampler = createVisualSampler(this.resolveMedia, (item, time) => {
|
|
25
28
|
const targetUs = toUs(time)
|
|
@@ -28,7 +31,7 @@ export class CursorVisualSampler {
|
|
|
28
31
|
if (!cursor) {
|
|
29
32
|
const source = this.resolveMedia(item.mediaHash)
|
|
30
33
|
const endUs = toUs(ms(item.start + item.duration))
|
|
31
|
-
cursor = this
|
|
34
|
+
cursor = this.createCursor(source, targetUs, endUs)
|
|
32
35
|
this.#videoCursors.set(item.id, cursor)
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -36,11 +39,9 @@ export class CursorVisualSampler {
|
|
|
36
39
|
})
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
if (timecode < this.#lastTimecode)
|
|
41
|
-
throw new Error(`Forward-only cursor regression: ${timecode}ms < ${this.#lastTimecode}ms`)
|
|
42
|
+
protected abstract createCursor(source: DecoderSource, startUs: number, endUs: number): VideoFrameCursor
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
protected sample(timecode: Ms) {
|
|
44
45
|
return this.#sampler.sample(this.timeline, timecode)
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -48,8 +49,26 @@ export class CursorVisualSampler {
|
|
|
48
49
|
await Promise.all([...this.#videoCursors.values()].map(c => c.cancel()))
|
|
49
50
|
this.#videoCursors.clear()
|
|
50
51
|
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* forward-only frame cursor optimized for export purposes.
|
|
56
|
+
* it uses mediabunny internally so the support for non-clients
|
|
57
|
+
* should be done from mediabunny custom decoder/encoder
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
export class CursorVisualSampler extends BaseVisualSampler {
|
|
61
|
+
#lastTimecode = -Infinity
|
|
62
|
+
|
|
63
|
+
next(timecode: Ms) {
|
|
64
|
+
if (timecode < this.#lastTimecode)
|
|
65
|
+
throw new Error(`Forward-only cursor regression: ${timecode}ms < ${this.#lastTimecode}ms`)
|
|
66
|
+
|
|
67
|
+
this.#lastTimecode = timecode
|
|
68
|
+
return this.sample(timecode)
|
|
69
|
+
}
|
|
51
70
|
|
|
52
|
-
|
|
71
|
+
protected createCursor(source: DecoderSource, startUs: number, endUs: number): VideoFrameCursor {
|
|
53
72
|
const video = this.driver.decodeVideo({source, start: startUs / 1_000_000, end: endUs / 1_000_000})
|
|
54
73
|
const reader = video.readable.getReader()
|
|
55
74
|
|
|
@@ -118,12 +137,138 @@ export class CursorVisualSampler {
|
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
|
|
121
|
-
|
|
140
|
+
export class ReverseCursorVisualSampler extends BaseVisualSampler {
|
|
141
|
+
#lastTimecode = Infinity
|
|
122
142
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
143
|
+
next(timecode: Ms) {
|
|
144
|
+
if (timecode > this.#lastTimecode)
|
|
145
|
+
throw new Error(`Reverse-only cursor regression: ${timecode}ms > ${this.#lastTimecode}ms`)
|
|
127
146
|
|
|
128
|
-
|
|
147
|
+
this.#lastTimecode = timecode
|
|
148
|
+
return this.sample(timecode)
|
|
149
|
+
}
|
|
129
150
|
|
|
151
|
+
protected createCursor(source: DecoderSource, _initialTargetUs: number, endUs: number): VideoFrameCursor {
|
|
152
|
+
const startUs = 0
|
|
153
|
+
const windowUs = 1_000_000
|
|
154
|
+
const prefetchThreshold = windowUs * 0.5
|
|
155
|
+
|
|
156
|
+
let frames: VideoFrame[] = []
|
|
157
|
+
let windowStart = Infinity
|
|
158
|
+
let windowEnd = -Infinity
|
|
159
|
+
let input: Input | null = null
|
|
160
|
+
let sink: VideoSampleSink | null = null
|
|
161
|
+
let prefetchPromise: Promise<{frames: VideoFrame[], windowStart: number, windowEnd: number}> | null = null
|
|
162
|
+
let canceled = false
|
|
163
|
+
|
|
164
|
+
const clear = () => {
|
|
165
|
+
for (const frame of frames)
|
|
166
|
+
frame.close()
|
|
167
|
+
frames = []
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const getSink = async () => {
|
|
171
|
+
if (sink) return sink
|
|
172
|
+
|
|
173
|
+
input = new Input({
|
|
174
|
+
source: await loadDecoderSource(source),
|
|
175
|
+
formats: ALL_FORMATS,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const track = await input.getPrimaryVideoTrack()
|
|
179
|
+
sink = track && await track.canDecode()
|
|
180
|
+
? new VideoSampleSink(track)
|
|
181
|
+
: null
|
|
182
|
+
|
|
183
|
+
return sink
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const fetchFrames = async (targetUs: number) => {
|
|
187
|
+
const wEnd = Math.min(endUs, targetUs + 1)
|
|
188
|
+
const wStart = Math.max(startUs, wEnd - windowUs)
|
|
189
|
+
const newFrames: VideoFrame[] = []
|
|
190
|
+
|
|
191
|
+
const videoSink = await getSink()
|
|
192
|
+
if (videoSink) {
|
|
193
|
+
for await (const sample of videoSink.samples(wStart / 1_000_000, wEnd / 1_000_000)) {
|
|
194
|
+
newFrames.push(sample.toVideoFrame())
|
|
195
|
+
sample.close()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {frames: newFrames, windowStart: wStart, windowEnd: wEnd}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const loadWindow = async (targetUs: number) => {
|
|
203
|
+
clear()
|
|
204
|
+
const result = await fetchFrames(targetUs)
|
|
205
|
+
frames = result.frames
|
|
206
|
+
windowStart = result.windowStart
|
|
207
|
+
windowEnd = result.windowEnd
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
async next(targetUs: number): Promise<VideoFrame | undefined> {
|
|
212
|
+
if (canceled)
|
|
213
|
+
return undefined
|
|
214
|
+
|
|
215
|
+
if (targetUs < windowStart || targetUs > windowEnd) {
|
|
216
|
+
if (prefetchPromise) {
|
|
217
|
+
const prefetched = await prefetchPromise
|
|
218
|
+
prefetchPromise = null
|
|
219
|
+
|
|
220
|
+
if (canceled) {
|
|
221
|
+
for (const f of prefetched.frames) f.close()
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (targetUs >= prefetched.windowStart && targetUs <= prefetched.windowEnd) {
|
|
226
|
+
clear()
|
|
227
|
+
frames = prefetched.frames
|
|
228
|
+
windowStart = prefetched.windowStart
|
|
229
|
+
windowEnd = prefetched.windowEnd
|
|
230
|
+
} else {
|
|
231
|
+
for (const f of prefetched.frames) f.close()
|
|
232
|
+
await loadWindow(targetUs)
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
await loadWindow(targetUs)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!prefetchPromise && targetUs < windowStart + prefetchThreshold && windowStart > startUs)
|
|
240
|
+
prefetchPromise = fetchFrames(windowStart - 1)
|
|
241
|
+
|
|
242
|
+
let best: VideoFrame | undefined
|
|
243
|
+
let bestDistance = Infinity
|
|
244
|
+
|
|
245
|
+
for (const frame of frames) {
|
|
246
|
+
const distance = Math.abs((frame.timestamp ?? targetUs) - targetUs)
|
|
247
|
+
if (distance < bestDistance) {
|
|
248
|
+
best = frame
|
|
249
|
+
bestDistance = distance
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return best ? new VideoFrame(best) : undefined
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async cancel() {
|
|
257
|
+
canceled = true
|
|
258
|
+
const pending = prefetchPromise
|
|
259
|
+
prefetchPromise = null
|
|
260
|
+
|
|
261
|
+
const prefetched = await pending?.catch(() => null)
|
|
262
|
+
if (prefetched)
|
|
263
|
+
for (const f of prefetched.frames) f.close()
|
|
264
|
+
|
|
265
|
+
clear()
|
|
266
|
+
input?.dispose()
|
|
267
|
+
input = null
|
|
268
|
+
sink = null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const toUs = (ms: Ms) => Math.round(ms * 1_000)
|
|
@@ -3,10 +3,10 @@ import {Fps} from '../../../../units/fps.js'
|
|
|
3
3
|
import {ms, Ms} from '../../../../units/ms.js'
|
|
4
4
|
import {Driver} from '../../../../driver/driver.js'
|
|
5
5
|
import {realtime} from '../../parts/schedulers.js'
|
|
6
|
+
import {seconds} from '../../../../units/seconds.js'
|
|
6
7
|
import {TimelineFile} from '../../../parts/basics.js'
|
|
7
8
|
import {computeItemDuration} from '../../parts/handy.js'
|
|
8
|
-
import {
|
|
9
|
-
import {CursorVisualSampler} from '../../export/parts/cursor.js'
|
|
9
|
+
import {CursorVisualSampler, ReverseCursorVisualSampler} from '../../export/parts/cursor.js'
|
|
10
10
|
import {DecoderSource} from '../../../../driver/fns/schematic.js'
|
|
11
11
|
import {createAudioSampler} from '../../parts/samplers/audio/sampler.js'
|
|
12
12
|
import {createVisualSampler} from '../../parts/samplers/visual/sampler.js'
|
|
@@ -15,9 +15,11 @@ export class Playback {
|
|
|
15
15
|
audioSampler
|
|
16
16
|
seekVisualSampler
|
|
17
17
|
playVisualSampler: CursorVisualSampler | null = null
|
|
18
|
+
reversePlayVisualSampler: ReverseCursorVisualSampler | null = null
|
|
18
19
|
|
|
19
20
|
#playbackStart = ms(0)
|
|
20
21
|
#audioStartSec: number | null = null
|
|
22
|
+
#playbackRate = 1
|
|
21
23
|
|
|
22
24
|
#controller = realtime()
|
|
23
25
|
onTick = this.#controller.onTick
|
|
@@ -49,12 +51,19 @@ export class Playback {
|
|
|
49
51
|
|
|
50
52
|
async #samples() {
|
|
51
53
|
for await (const _ of this.#controller.ticks()) {
|
|
52
|
-
const
|
|
54
|
+
const time = this.currentTime
|
|
55
|
+
const layers = this.#playbackRate >= 0
|
|
56
|
+
? await this.playVisualSampler?.next(time) ?? []
|
|
57
|
+
: await this.reversePlayVisualSampler?.next(time) ?? []
|
|
53
58
|
|
|
54
59
|
const frame = await this.driver.composite(layers)
|
|
55
60
|
frame.close()
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
const hasEnded = this.#playbackRate >= 0
|
|
63
|
+
? time >= this.duration
|
|
64
|
+
: time <= 0
|
|
65
|
+
|
|
66
|
+
if (hasEnded)
|
|
58
67
|
this.pause()
|
|
59
68
|
}
|
|
60
69
|
}
|
|
@@ -74,34 +83,27 @@ export class Playback {
|
|
|
74
83
|
this.#playbackStart = this.currentTime
|
|
75
84
|
this.#audioStartSec = this.audioContext.currentTime
|
|
76
85
|
|
|
77
|
-
this.#
|
|
78
|
-
this.#audioAbort = new AbortController()
|
|
79
|
-
|
|
80
|
-
for (const node of this.audioNodes)
|
|
81
|
-
node.stop()
|
|
82
|
-
|
|
83
|
-
this.audioNodes.clear()
|
|
84
|
-
|
|
86
|
+
this.#stopAudio()
|
|
85
87
|
this.playVisualSampler = new CursorVisualSampler(this.driver, this.resolveMedia, this.timeline)
|
|
88
|
+
this.reversePlayVisualSampler = new ReverseCursorVisualSampler(this.driver, this.resolveMedia, this.timeline)
|
|
86
89
|
|
|
87
90
|
this.#controller.play()
|
|
88
|
-
this.#startAudio(
|
|
91
|
+
this.#startAudio()
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
pause() {
|
|
92
95
|
this.#playbackStart = this.currentTime
|
|
93
96
|
this.#controller.pause()
|
|
94
|
-
this.#
|
|
95
|
-
|
|
96
|
-
for (const node of this.audioNodes)
|
|
97
|
-
node.stop()
|
|
98
|
-
|
|
99
|
-
this.audioNodes.clear()
|
|
97
|
+
this.#stopAudio()
|
|
100
98
|
|
|
101
99
|
if (this.playVisualSampler) {
|
|
102
100
|
this.playVisualSampler.cancel()
|
|
103
101
|
this.playVisualSampler = null
|
|
104
102
|
}
|
|
103
|
+
if (this.reversePlayVisualSampler) {
|
|
104
|
+
this.reversePlayVisualSampler.cancel()
|
|
105
|
+
this.reversePlayVisualSampler = null
|
|
106
|
+
}
|
|
105
107
|
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -117,14 +119,66 @@ export class Playback {
|
|
|
117
119
|
return this.#playbackStart
|
|
118
120
|
|
|
119
121
|
const elapsedMs = (this.audioContext.currentTime - this.#audioStartSec) * 1000
|
|
120
|
-
|
|
122
|
+
const current = this.#playbackStart + elapsedMs * this.#playbackRate
|
|
123
|
+
return ms(Math.max(0, Math.min(this.duration, current)))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get playbackRate() {
|
|
127
|
+
return this.#playbackRate
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
set playbackRate(rate: number) {
|
|
131
|
+
if (!Number.isFinite(rate) || rate === 0)
|
|
132
|
+
throw new Error(`Invalid playback rate "${rate}".`)
|
|
133
|
+
|
|
134
|
+
this.#playbackStart = this.currentTime
|
|
135
|
+
this.#audioStartSec = this.#controller.isPlaying()
|
|
136
|
+
? this.audioContext.currentTime
|
|
137
|
+
: null
|
|
138
|
+
const wasReversed = this.#playbackRate < 0
|
|
139
|
+
this.#playbackRate = rate
|
|
140
|
+
|
|
141
|
+
if (this.#controller.isPlaying()) {
|
|
142
|
+
if (wasReversed && rate > 0) {
|
|
143
|
+
this.playVisualSampler?.cancel()
|
|
144
|
+
this.playVisualSampler = new CursorVisualSampler(this.driver, this.resolveMedia, this.timeline)
|
|
145
|
+
}
|
|
146
|
+
else if (!wasReversed && rate < 0) {
|
|
147
|
+
this.reversePlayVisualSampler?.cancel()
|
|
148
|
+
this.reversePlayVisualSampler = new ReverseCursorVisualSampler(this.driver, this.resolveMedia, this.timeline)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.#syncAudio()
|
|
152
|
+
}
|
|
121
153
|
}
|
|
122
154
|
|
|
123
155
|
setFps(fps: Fps) {
|
|
124
156
|
this.#controller.setFPS(fps)
|
|
125
157
|
}
|
|
126
158
|
|
|
127
|
-
|
|
159
|
+
#syncAudio() {
|
|
160
|
+
this.#stopAudio()
|
|
161
|
+
this.#startAudio()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#stopAudio() {
|
|
165
|
+
this.#audioAbort?.abort()
|
|
166
|
+
this.#audioAbort = null
|
|
167
|
+
|
|
168
|
+
for (const node of this.audioNodes)
|
|
169
|
+
node.stop()
|
|
170
|
+
|
|
171
|
+
this.audioNodes.clear()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async #startAudio() {
|
|
175
|
+
if (this.#playbackRate !== 1)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
const from = seconds(this.#playbackStart / 1000)
|
|
179
|
+
this.#audioAbort = new AbortController()
|
|
180
|
+
const signal = this.#audioAbort.signal
|
|
181
|
+
|
|
128
182
|
const ctx = this.audioContext
|
|
129
183
|
|
|
130
184
|
if (this.#audioStartSec === null)
|
|
@@ -57,6 +57,14 @@ export class VideoPlayer {
|
|
|
57
57
|
return this.playback.currentTime
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
get playbackRate() {
|
|
61
|
+
return this.playback.playbackRate
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
set playbackRate(rate: number) {
|
|
65
|
+
this.playback.playbackRate = rate
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
/**
|
|
61
69
|
call this whenever your timeline state changes
|
|
62
70
|
*/
|