@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnimedia/omnitool",
3
- "version": "1.1.0-88",
3
+ "version": "1.1.0-89",
4
4
  "description": "open source video processing tools",
5
5
  "license": "MIT",
6
6
  "author": "Przemysław Gałęzki",
@@ -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
- * forward-only frame cursor optimized for export purposes.
10
- * it uses mediabunny internally so the support for non-clients
11
- * should be done from mediabunny custom decoder/encoder
12
- */
11
+ type StreamCursor<T> = {
12
+ next(target: number): Promise<T | undefined>
13
+ cancel(): Promise<void>
14
+ }
13
15
 
14
- export class CursorVisualSampler {
15
- #lastTimecode = -Infinity
16
- #videoCursors = new Map<number, VideoFrameCursor>()
17
- #sampler
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
- private driver: Driver,
21
- private resolveMedia: (hash: string) => DecoderSource,
22
- private timeline: TimelineFile
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.#createVideoCursor(source, targetUs, endUs)
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
- next(timecode: Ms) {
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
- this.#lastTimecode = timecode
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
- #createVideoCursor(source: DecoderSource, startUs: number, endUs: number): VideoFrameCursor {
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
- const toUs = (ms: Ms) => Math.round(ms * 1_000)
140
+ export class ReverseCursorVisualSampler extends BaseVisualSampler {
141
+ #lastTimecode = Infinity
122
142
 
123
- type StreamCursor<T> = {
124
- next(target: number): Promise<T | undefined>
125
- cancel(): Promise<void>
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
- type VideoFrameCursor = StreamCursor<VideoFrame>
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 {seconds, Seconds} from '../../../../units/seconds.js'
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 layers = await this.playVisualSampler?.next(this.currentTime) ?? []
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
- if (this.currentTime >= this.duration)
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.#audioAbort?.abort()
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(this.#audioAbort.signal, seconds(this.#playbackStart / 1000))
91
+ this.#startAudio()
89
92
  }
90
93
 
91
94
  pause() {
92
95
  this.#playbackStart = this.currentTime
93
96
  this.#controller.pause()
94
- this.#audioAbort?.abort()
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
- return ms(this.#playbackStart + elapsedMs)
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
- async #startAudio(signal: AbortSignal, from: Seconds) {
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
  */