@clockworkdog/cogs-client 3.0.0-alpha.5 → 3.0.0-alpha.6
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.
|
@@ -44,7 +44,9 @@ export class ClipManager {
|
|
|
44
44
|
loop = async () => {
|
|
45
45
|
if (this.isConnected()) {
|
|
46
46
|
this.update();
|
|
47
|
-
|
|
47
|
+
if (isFinite(this.delay)) {
|
|
48
|
+
this.timeout = setTimeout(this.loop, this.delay);
|
|
49
|
+
}
|
|
48
50
|
}
|
|
49
51
|
else {
|
|
50
52
|
this.destroy();
|
|
@@ -3,8 +3,10 @@ import { ClipManager } from './ClipManager';
|
|
|
3
3
|
export declare class VideoManager extends ClipManager<VideoState> {
|
|
4
4
|
private videoElement?;
|
|
5
5
|
private isSeeking;
|
|
6
|
+
private timeToIntercept;
|
|
6
7
|
constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: VideoState);
|
|
7
8
|
private updateVideoElement;
|
|
9
|
+
private get videoDuration();
|
|
8
10
|
/**
|
|
9
11
|
* Helper function to seek to a specified time.
|
|
10
12
|
* Works with the update loop to poll until seeked event has fired.
|
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import { defaultVideoOptions } from '../types/MediaSchema';
|
|
2
2
|
import { getStateAtTime } from '../utils/getStateAtTime';
|
|
3
3
|
import { ClipManager } from './ClipManager';
|
|
4
|
-
const
|
|
4
|
+
const DEFAULT_VIDEO_POLLING_MS = 1_000;
|
|
5
5
|
const TARGET_SYNC_THRESHOLD_MS = 10; // If we're closer than this we're good enough
|
|
6
6
|
const MAX_SYNC_THRESHOLD_MS = 1_000; // If we're further away than this, we'll seek instead
|
|
7
7
|
const SEEK_LOOKAHEAD_MS = 200; // We won't seek ahead instantly, so lets seek ahead
|
|
8
|
-
const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.
|
|
8
|
+
const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.15; // Don't speed up or slow down the video more than this
|
|
9
|
+
const INTERCEPTION_EARLY_CHECK_IN = 0.7; // When on course for interception of server time, how early to check in beforehand.
|
|
9
10
|
// We smoothly ramp playbackRate up and down
|
|
10
11
|
const PLAYBACK_ADJUSTMENT_SMOOTHING = 0.3;
|
|
11
12
|
function playbackSmoothing(deltaTime) {
|
|
12
|
-
return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / MAX_SYNC_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
|
|
13
|
+
return -Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / MAX_SYNC_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
|
|
14
|
+
}
|
|
15
|
+
// If we notice that at the end of the current playback, we set t=0 we should loop
|
|
16
|
+
const LOOPING_EPSILON_MS = 5;
|
|
17
|
+
function isLooping(state, time, duration) {
|
|
18
|
+
const currentState = getStateAtTime(state, time);
|
|
19
|
+
if (!currentState)
|
|
20
|
+
return false;
|
|
21
|
+
const { t, rate } = currentState;
|
|
22
|
+
if (t === undefined || rate === undefined)
|
|
23
|
+
return false;
|
|
24
|
+
const nextTemporalKeyframe = state.keyframes.filter(([t, kf]) => t > time && (kf?.set?.t !== undefined || kf?.set?.rate !== undefined))[0];
|
|
25
|
+
if (nextTemporalKeyframe?.[1]?.set?.t !== 0)
|
|
26
|
+
return false;
|
|
27
|
+
const timeRemaining = (duration - t) / rate;
|
|
28
|
+
const timeUntilKeyframe = nextTemporalKeyframe[0] - time;
|
|
29
|
+
return Math.abs(timeRemaining - timeUntilKeyframe) <= LOOPING_EPSILON_MS;
|
|
13
30
|
}
|
|
14
31
|
export class VideoManager extends ClipManager {
|
|
15
32
|
videoElement;
|
|
33
|
+
// We seek to another part of the video and do nothing until we get there
|
|
16
34
|
isSeeking = false;
|
|
35
|
+
// We change playbackRate to intercept the server time of the video and don't change course until we intercept
|
|
36
|
+
timeToIntercept = undefined;
|
|
17
37
|
constructor(surfaceElement, clipElement, state) {
|
|
18
38
|
super(surfaceElement, clipElement, state);
|
|
19
39
|
this.clipElement = clipElement;
|
|
@@ -26,6 +46,13 @@ export class VideoManager extends ClipManager {
|
|
|
26
46
|
this.videoElement.style.width = '100%';
|
|
27
47
|
this.videoElement.style.height = '100%';
|
|
28
48
|
}
|
|
49
|
+
get videoDuration() {
|
|
50
|
+
if (!this.videoElement)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (this.videoElement.readyState < HTMLMediaElement.HAVE_METADATA)
|
|
53
|
+
return undefined;
|
|
54
|
+
return this.videoElement.duration * 1000;
|
|
55
|
+
}
|
|
29
56
|
/**
|
|
30
57
|
* Helper function to seek to a specified time.
|
|
31
58
|
* Works with the update loop to poll until seeked event has fired.
|
|
@@ -34,6 +61,7 @@ export class VideoManager extends ClipManager {
|
|
|
34
61
|
if (!this.videoElement)
|
|
35
62
|
return;
|
|
36
63
|
this.videoElement.addEventListener('seeked', () => {
|
|
64
|
+
console.debug('seeked');
|
|
37
65
|
this.isSeeking = false;
|
|
38
66
|
}, { once: true, passive: true });
|
|
39
67
|
this.videoElement.currentTime = time / 1_000;
|
|
@@ -42,9 +70,9 @@ export class VideoManager extends ClipManager {
|
|
|
42
70
|
// Update loop used to poll until seek finished
|
|
43
71
|
if (this.isSeeking)
|
|
44
72
|
return;
|
|
45
|
-
this.delay = DEFAULT_VIDEO_POLLING;
|
|
46
73
|
// Does the <video /> element need adding/removing?
|
|
47
|
-
const
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const currentState = getStateAtTime(this._state, now);
|
|
48
76
|
if (currentState) {
|
|
49
77
|
if (!this.videoElement || !this.isConnected(this.videoElement)) {
|
|
50
78
|
this.updateVideoElement();
|
|
@@ -74,34 +102,91 @@ export class VideoManager extends ClipManager {
|
|
|
74
102
|
if (this.videoElement.volume !== volume) {
|
|
75
103
|
this.videoElement.volume = volume;
|
|
76
104
|
}
|
|
77
|
-
|
|
105
|
+
const duration = this.videoDuration;
|
|
106
|
+
if (duration !== undefined) {
|
|
107
|
+
// Is the video looping?
|
|
108
|
+
if (isLooping(this._state, now, duration)) {
|
|
109
|
+
if (!this.videoElement.loop) {
|
|
110
|
+
console.debug('starting loop');
|
|
111
|
+
this.videoElement.loop = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
if (this.videoElement.loop) {
|
|
116
|
+
console.debug('stopping loop');
|
|
117
|
+
this.videoElement.loop = false;
|
|
118
|
+
}
|
|
119
|
+
// Has the video finished
|
|
120
|
+
if (t > duration) {
|
|
121
|
+
console.debug('ended');
|
|
122
|
+
this.delay = Infinity;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Should the video be playing
|
|
78
128
|
if (this.videoElement.paused && rate > 0) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
if (duration === undefined || duration > t) {
|
|
130
|
+
this.videoElement.play().catch(() => {
|
|
131
|
+
// Do nothing - this will be retried in the next loop
|
|
132
|
+
});
|
|
133
|
+
}
|
|
82
134
|
}
|
|
83
135
|
const currentTime = this.videoElement.currentTime * 1000;
|
|
84
136
|
const deltaTime = currentTime - t;
|
|
85
137
|
const deltaTimeAbs = Math.abs(deltaTime);
|
|
86
|
-
|
|
138
|
+
// Handle current playbackRateAdjustment
|
|
139
|
+
if (this.timeToIntercept !== undefined) {
|
|
140
|
+
if (deltaTimeAbs <= TARGET_SYNC_THRESHOLD_MS) {
|
|
141
|
+
// We've successfully got back on track
|
|
142
|
+
console.log('intercepted', `${deltaTime.toFixed(0)}ms`);
|
|
143
|
+
this.timeToIntercept = undefined;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const newTimeToIntercept = deltaTime / (rate - this.videoElement.playbackRate);
|
|
147
|
+
if (newTimeToIntercept < this.timeToIntercept && newTimeToIntercept > 0) {
|
|
148
|
+
// We're getting there, let's stay on course
|
|
149
|
+
console.debug(`intercepting ${newTimeToIntercept.toFixed(0)}ms`, `${deltaTime.toFixed(0)}ms`);
|
|
150
|
+
this.timeToIntercept = newTimeToIntercept;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// We've gone too far
|
|
154
|
+
console.debug('missed intercept', deltaTime, this.timeToIntercept, newTimeToIntercept);
|
|
155
|
+
this.timeToIntercept = undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
87
159
|
switch (true) {
|
|
88
|
-
case deltaTimeAbs <= TARGET_SYNC_THRESHOLD_MS:
|
|
160
|
+
case deltaTimeAbs <= TARGET_SYNC_THRESHOLD_MS: {
|
|
89
161
|
// We are on course:
|
|
90
162
|
// - The video is within accepted latency of the server time
|
|
91
163
|
// - The playback rate is aligned with the server rate
|
|
164
|
+
console.debug(`${rate}x`, deltaTime.toFixed(0));
|
|
165
|
+
this.timeToIntercept = undefined;
|
|
92
166
|
if (this.videoElement.playbackRate !== rate) {
|
|
93
167
|
this.videoElement.playbackRate = rate;
|
|
94
168
|
}
|
|
169
|
+
this.delay = DEFAULT_VIDEO_POLLING_MS;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case this.timeToIntercept !== undefined:
|
|
173
|
+
// We are currently on course to intercept
|
|
174
|
+
// - We don't want to adjust the playbackRate excessively to pop audio
|
|
175
|
+
// - We are on track to get back on time. So we can wait.
|
|
176
|
+
this.delay = this.timeToIntercept * INTERCEPTION_EARLY_CHECK_IN;
|
|
95
177
|
break;
|
|
96
|
-
case rate > 0 && deltaTimeAbs > TARGET_SYNC_THRESHOLD_MS && deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS: {
|
|
178
|
+
case rate > 0 && deltaTimeAbs > TARGET_SYNC_THRESHOLD_MS && deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS && this.timeToIntercept === undefined: {
|
|
97
179
|
// We are close, we can smoothly adjust with playbackRate:
|
|
98
180
|
// - The video must be playing
|
|
99
181
|
// - We must be close in time to the server time
|
|
100
182
|
const playbackRateAdjustment = playbackSmoothing(deltaTime);
|
|
101
|
-
const adjustedPlaybackRate = Math.max(0, rate
|
|
183
|
+
const adjustedPlaybackRate = Math.max(0, rate + playbackRateAdjustment);
|
|
184
|
+
this.timeToIntercept = deltaTime / (rate - adjustedPlaybackRate);
|
|
185
|
+
console.debug(`${adjustedPlaybackRate.toFixed(2)}x`, `${deltaTime.toFixed(0)}ms`);
|
|
102
186
|
if (this.videoElement.playbackRate !== adjustedPlaybackRate) {
|
|
103
187
|
this.videoElement.playbackRate = adjustedPlaybackRate;
|
|
104
188
|
}
|
|
189
|
+
this.delay = this.timeToIntercept * INTERCEPTION_EARLY_CHECK_IN;
|
|
105
190
|
break;
|
|
106
191
|
}
|
|
107
192
|
default: {
|
|
@@ -111,8 +196,9 @@ export class VideoManager extends ClipManager {
|
|
|
111
196
|
this.videoElement.playbackRate = rate;
|
|
112
197
|
}
|
|
113
198
|
// delay to poll until seeked
|
|
199
|
+
console.debug('seeking');
|
|
114
200
|
this.delay = 10;
|
|
115
|
-
this.seekTo(t + rate *
|
|
201
|
+
this.seekTo(t + rate * SEEK_LOOKAHEAD_MS);
|
|
116
202
|
break;
|
|
117
203
|
}
|
|
118
204
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Connect to COGS to build a custom Media Master",
|
|
4
4
|
"author": "Clockwork Dog <info@clockwork.dog>",
|
|
5
5
|
"homepage": "https://github.com/clockwork-dog/cogs-sdk/tree/main/packages/javascript",
|
|
6
|
-
"version": "3.0.0-alpha.
|
|
6
|
+
"version": "3.0.0-alpha.6",
|
|
7
7
|
"keywords": [],
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"cy:generate": "cypress run --e2e"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@clockworkdog/timesync": "^3.0.0-alpha.
|
|
40
|
+
"@clockworkdog/timesync": "^3.0.0-alpha.6",
|
|
41
41
|
"howler": "clockwork-dog/howler.js#fix-looping-clips",
|
|
42
42
|
"reconnecting-websocket": "^4.4.0",
|
|
43
43
|
"zod": "^4.1.13"
|