@clockworkdog/cogs-client 3.0.0-alpha.5 → 3.0.0-alpha.7

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/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export type { default as MediaClipStateMessage } from './types/MediaClipStateMes
5
5
  export type { default as ShowPhase } from './types/ShowPhase';
6
6
  export type { default as MediaObjectFit } from './types/MediaObjectFit';
7
7
  export * as MediaSchema from './types/MediaSchema';
8
+ export * from './state-based/SurfaceManager';
8
9
  export { default as CogsAudioPlayer } from './AudioPlayer';
9
10
  export { default as CogsVideoPlayer } from './VideoPlayer';
10
11
  export { SurfaceManager } from './state-based/SurfaceManager';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { default as CogsConnection } from './CogsConnection';
2
2
  export * from './CogsConnection';
3
3
  export * as MediaSchema from './types/MediaSchema';
4
+ export * from './state-based/SurfaceManager';
4
5
  export { default as CogsAudioPlayer } from './AudioPlayer';
5
6
  export { default as CogsVideoPlayer } from './VideoPlayer';
6
7
  export { SurfaceManager } from './state-based/SurfaceManager';
@@ -3,7 +3,7 @@ import { ClipManager } from './ClipManager';
3
3
  export declare class AudioManager extends ClipManager<AudioState> {
4
4
  private audioElement?;
5
5
  private isSeeking;
6
- constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: AudioState);
6
+ constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: AudioState, constructAssetURL: (file: string) => string);
7
7
  private updateAudioElement;
8
8
  /**
9
9
  * Helper function to seek to a specified time.
@@ -14,8 +14,8 @@ function playbackSmoothing(deltaTime) {
14
14
  export class AudioManager extends ClipManager {
15
15
  audioElement;
16
16
  isSeeking = false;
17
- constructor(surfaceElement, clipElement, state) {
18
- super(surfaceElement, clipElement, state);
17
+ constructor(surfaceElement, clipElement, state, constructAssetURL) {
18
+ super(surfaceElement, clipElement, state, constructAssetURL);
19
19
  this.clipElement = clipElement;
20
20
  }
21
21
  updateAudioElement() {
@@ -53,9 +53,10 @@ export class AudioManager extends ClipManager {
53
53
  if (!currentState || !this.audioElement)
54
54
  return;
55
55
  const { t, rate, volume } = { ...defaultAudioOptions, ...currentState };
56
- // this.audioElement.src will be a fully qualified URL
57
- if (!this.audioElement.src.endsWith(this._state.file)) {
58
- this.audioElement.src = this._state.file;
56
+ // this.videoElement.src will be a fully qualified URL
57
+ const assetURL = this.constructAssetURL(this._state.file);
58
+ if (!this.audioElement.src.includes(assetURL)) {
59
+ this.audioElement.src = assetURL;
59
60
  }
60
61
  if (this.audioElement.volume !== volume) {
61
62
  this.audioElement.volume = volume;
@@ -6,7 +6,8 @@ import { MediaClipState } from '../types/MediaSchema';
6
6
  export declare abstract class ClipManager<T extends MediaClipState> {
7
7
  private surfaceElement;
8
8
  protected clipElement: HTMLElement;
9
- constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: T);
9
+ protected constructAssetURL: (file: string) => string;
10
+ constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: T, constructAssetURL: (file: string) => string);
10
11
  /**
11
12
  * This is the delay to be used in the update loop.
12
13
  * It is intended to be dynamic for each loop.
@@ -6,9 +6,11 @@ const DEFAULT_DELAY = 1_000;
6
6
  export class ClipManager {
7
7
  surfaceElement;
8
8
  clipElement;
9
- constructor(surfaceElement, clipElement, state) {
9
+ constructAssetURL;
10
+ constructor(surfaceElement, clipElement, state, constructAssetURL) {
10
11
  this.surfaceElement = surfaceElement;
11
12
  this.clipElement = clipElement;
13
+ this.constructAssetURL = constructAssetURL;
12
14
  this._state = state;
13
15
  // Allow the class to be constructed, then call the loop
14
16
  setTimeout(this.loop);
@@ -44,7 +46,9 @@ export class ClipManager {
44
46
  loop = async () => {
45
47
  if (this.isConnected()) {
46
48
  this.update();
47
- this.timeout = setTimeout(this.loop, this.delay);
49
+ if (isFinite(this.delay)) {
50
+ this.timeout = setTimeout(this.loop, this.delay);
51
+ }
48
52
  }
49
53
  else {
50
54
  this.destroy();
@@ -2,7 +2,7 @@ import { ImageState } from '../types/MediaSchema';
2
2
  import { ClipManager } from './ClipManager';
3
3
  export declare class ImageManager extends ClipManager<ImageState> {
4
4
  private imageElement?;
5
- constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: ImageState);
5
+ constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: ImageState, constructAssetURL: (file: string) => string);
6
6
  private updateImageElement;
7
7
  protected update(): void;
8
8
  destroy(): void;
@@ -3,8 +3,8 @@ import { getStateAtTime } from '../utils/getStateAtTime';
3
3
  import { ClipManager } from './ClipManager';
4
4
  export class ImageManager extends ClipManager {
5
5
  imageElement;
6
- constructor(surfaceElement, clipElement, state) {
7
- super(surfaceElement, clipElement, state);
6
+ constructor(surfaceElement, clipElement, state, constructAssetURL) {
7
+ super(surfaceElement, clipElement, state, constructAssetURL);
8
8
  this.clipElement = clipElement;
9
9
  }
10
10
  updateImageElement() {
@@ -28,9 +28,10 @@ export class ImageManager extends ClipManager {
28
28
  }
29
29
  if (!this.imageElement || !currentState)
30
30
  return;
31
- // this.imageElement.src will be a fully qualified URL
32
- if (!this.imageElement.src.startsWith(this._state.file)) {
33
- this.imageElement.src = this._state.file;
31
+ // this.videoElement.src will be a fully qualified URL
32
+ const assetURL = this.constructAssetURL(this._state.file);
33
+ if (!this.imageElement.src.includes(assetURL)) {
34
+ this.imageElement.src = assetURL;
34
35
  }
35
36
  if (this.imageElement.style.objectFit !== this._state.fit) {
36
37
  this.imageElement.style.objectFit = this._state.fit;
@@ -6,11 +6,12 @@ export declare const DATA_CLIP_ID = "data-clip-id";
6
6
  * - Instantiate a ClipManager attached to each respective element
7
7
  */
8
8
  export declare class SurfaceManager {
9
+ private constructAssetUrl;
9
10
  private _state;
10
11
  setState(newState: MediaSurfaceState): void;
11
12
  private _element;
12
13
  get element(): HTMLDivElement;
13
14
  private resources;
14
- constructor(testState?: MediaSurfaceState);
15
+ constructor(constructAssetUrl: (file: string) => string, testState?: MediaSurfaceState);
15
16
  update(): void;
16
17
  }
@@ -8,6 +8,7 @@ export const DATA_CLIP_ID = 'data-clip-id';
8
8
  * - Instantiate a ClipManager attached to each respective element
9
9
  */
10
10
  export class SurfaceManager {
11
+ constructAssetUrl;
11
12
  _state = {};
12
13
  setState(newState) {
13
14
  this._state = newState;
@@ -18,8 +19,10 @@ export class SurfaceManager {
18
19
  return this._element;
19
20
  }
20
21
  resources = {};
21
- constructor(testState) {
22
+ constructor(constructAssetUrl, testState) {
23
+ this.constructAssetUrl = constructAssetUrl;
22
24
  this._element = document.createElement('div');
25
+ this._element.className = 'surface-manager';
23
26
  this._element.style.width = '100%';
24
27
  this._element.style.height = '100%';
25
28
  this._state = testState || {};
@@ -62,13 +65,13 @@ export class SurfaceManager {
62
65
  if (!resource.manager) {
63
66
  switch (clip.type) {
64
67
  case 'image':
65
- resource.manager = new ImageManager(this._element, resource.element, clip);
68
+ resource.manager = new ImageManager(this._element, resource.element, clip, this.constructAssetUrl);
66
69
  break;
67
70
  case 'audio':
68
- resource.manager = new AudioManager(this._element, resource.element, clip);
71
+ resource.manager = new AudioManager(this._element, resource.element, clip, this.constructAssetUrl);
69
72
  break;
70
73
  case 'video':
71
- resource.manager = new VideoManager(this._element, resource.element, clip);
74
+ resource.manager = new VideoManager(this._element, resource.element, clip, this.constructAssetUrl);
72
75
  break;
73
76
  }
74
77
  }
@@ -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
- constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: VideoState);
6
+ private timeToIntercept;
7
+ constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: VideoState, constructAssetURL: (file: string) => string);
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,21 +1,41 @@
1
1
  import { defaultVideoOptions } from '../types/MediaSchema';
2
2
  import { getStateAtTime } from '../utils/getStateAtTime';
3
3
  import { ClipManager } from './ClipManager';
4
- const DEFAULT_VIDEO_POLLING = 1_000;
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.5;
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;
17
- constructor(surfaceElement, clipElement, state) {
18
- super(surfaceElement, clipElement, state);
35
+ // We change playbackRate to intercept the server time of the video and don't change course until we intercept
36
+ timeToIntercept = undefined;
37
+ constructor(surfaceElement, clipElement, state, constructAssetURL) {
38
+ super(surfaceElement, clipElement, state, constructAssetURL);
19
39
  this.clipElement = clipElement;
20
40
  }
21
41
  updateVideoElement() {
@@ -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 currentState = getStateAtTime(this._state, Date.now());
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();
@@ -58,8 +86,9 @@ export class VideoManager extends ClipManager {
58
86
  return;
59
87
  const { t, rate, volume } = { ...defaultVideoOptions, ...currentState };
60
88
  // this.videoElement.src will be a fully qualified URL
61
- if (!this.videoElement.src.endsWith(this._state.file)) {
62
- this.videoElement.src = this._state.file;
89
+ const assetURL = this.constructAssetURL(this._state.file);
90
+ if (!this.videoElement.src.includes(assetURL)) {
91
+ this.videoElement.src = assetURL;
63
92
  }
64
93
  if (this.videoElement.style.objectFit !== this._state.fit) {
65
94
  this.videoElement.style.objectFit = this._state.fit;
@@ -74,34 +103,91 @@ export class VideoManager extends ClipManager {
74
103
  if (this.videoElement.volume !== volume) {
75
104
  this.videoElement.volume = volume;
76
105
  }
77
- // Should the element be playing?
106
+ const duration = this.videoDuration;
107
+ if (duration !== undefined) {
108
+ // Is the video looping?
109
+ if (isLooping(this._state, now, duration)) {
110
+ if (!this.videoElement.loop) {
111
+ console.debug('starting loop');
112
+ this.videoElement.loop = true;
113
+ }
114
+ }
115
+ else {
116
+ if (this.videoElement.loop) {
117
+ console.debug('stopping loop');
118
+ this.videoElement.loop = false;
119
+ }
120
+ // Has the video finished
121
+ if (t > duration) {
122
+ console.debug('ended');
123
+ this.delay = Infinity;
124
+ return;
125
+ }
126
+ }
127
+ }
128
+ // Should the video be playing
78
129
  if (this.videoElement.paused && rate > 0) {
79
- this.videoElement.play().catch(() => {
80
- // Do nothing - this will be retried in the next loop
81
- });
130
+ if (duration === undefined || duration > t) {
131
+ this.videoElement.play().catch(() => {
132
+ // Do nothing - this will be retried in the next loop
133
+ });
134
+ }
82
135
  }
83
136
  const currentTime = this.videoElement.currentTime * 1000;
84
137
  const deltaTime = currentTime - t;
85
138
  const deltaTimeAbs = Math.abs(deltaTime);
86
- this.delay = 100;
139
+ // Handle current playbackRateAdjustment
140
+ if (this.timeToIntercept !== undefined) {
141
+ if (deltaTimeAbs <= TARGET_SYNC_THRESHOLD_MS) {
142
+ // We've successfully got back on track
143
+ console.log('intercepted', `${deltaTime.toFixed(0)}ms`);
144
+ this.timeToIntercept = undefined;
145
+ }
146
+ else {
147
+ const newTimeToIntercept = deltaTime / (rate - this.videoElement.playbackRate);
148
+ if (newTimeToIntercept < this.timeToIntercept && newTimeToIntercept > 0) {
149
+ // We're getting there, let's stay on course
150
+ console.debug(`intercepting ${newTimeToIntercept.toFixed(0)}ms`, `${deltaTime.toFixed(0)}ms`);
151
+ this.timeToIntercept = newTimeToIntercept;
152
+ }
153
+ else {
154
+ // We've gone too far
155
+ console.debug('missed intercept', deltaTime, this.timeToIntercept, newTimeToIntercept);
156
+ this.timeToIntercept = undefined;
157
+ }
158
+ }
159
+ }
87
160
  switch (true) {
88
- case deltaTimeAbs <= TARGET_SYNC_THRESHOLD_MS:
161
+ case deltaTimeAbs <= TARGET_SYNC_THRESHOLD_MS: {
89
162
  // We are on course:
90
163
  // - The video is within accepted latency of the server time
91
164
  // - The playback rate is aligned with the server rate
165
+ console.debug(`${rate}x`, deltaTime.toFixed(0));
166
+ this.timeToIntercept = undefined;
92
167
  if (this.videoElement.playbackRate !== rate) {
93
168
  this.videoElement.playbackRate = rate;
94
169
  }
170
+ this.delay = DEFAULT_VIDEO_POLLING_MS;
171
+ break;
172
+ }
173
+ case this.timeToIntercept !== undefined:
174
+ // We are currently on course to intercept
175
+ // - We don't want to adjust the playbackRate excessively to pop audio
176
+ // - We are on track to get back on time. So we can wait.
177
+ this.delay = this.timeToIntercept * INTERCEPTION_EARLY_CHECK_IN;
95
178
  break;
96
- case rate > 0 && deltaTimeAbs > TARGET_SYNC_THRESHOLD_MS && deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS: {
179
+ case rate > 0 && deltaTimeAbs > TARGET_SYNC_THRESHOLD_MS && deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS && this.timeToIntercept === undefined: {
97
180
  // We are close, we can smoothly adjust with playbackRate:
98
181
  // - The video must be playing
99
182
  // - We must be close in time to the server time
100
183
  const playbackRateAdjustment = playbackSmoothing(deltaTime);
101
- const adjustedPlaybackRate = Math.max(0, rate - playbackRateAdjustment);
184
+ const adjustedPlaybackRate = Math.max(0, rate + playbackRateAdjustment);
185
+ this.timeToIntercept = deltaTime / (rate - adjustedPlaybackRate);
186
+ console.debug(`${adjustedPlaybackRate.toFixed(2)}x`, `${deltaTime.toFixed(0)}ms`);
102
187
  if (this.videoElement.playbackRate !== adjustedPlaybackRate) {
103
188
  this.videoElement.playbackRate = adjustedPlaybackRate;
104
189
  }
190
+ this.delay = this.timeToIntercept * INTERCEPTION_EARLY_CHECK_IN;
105
191
  break;
106
192
  }
107
193
  default: {
@@ -111,8 +197,9 @@ export class VideoManager extends ClipManager {
111
197
  this.videoElement.playbackRate = rate;
112
198
  }
113
199
  // delay to poll until seeked
200
+ console.debug('seeking');
114
201
  this.delay = 10;
115
- this.seekTo(t + rate * (SEEK_LOOKAHEAD_MS / 1000));
202
+ this.seekTo(t + rate * SEEK_LOOKAHEAD_MS);
116
203
  break;
117
204
  }
118
205
  }
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.5",
6
+ "version": "3.0.0-alpha.7",
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.5",
40
+ "@clockworkdog/timesync": "^3.0.0-alpha.7",
41
41
  "howler": "clockwork-dog/howler.js#fix-looping-clips",
42
42
  "reconnecting-websocket": "^4.4.0",
43
43
  "zod": "^4.1.13"