@clockworkdog/cogs-client 3.0.0-alpha.16 → 3.0.0-alpha.17

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.
@@ -1,4 +1,6 @@
1
1
  import { getStateAtTime } from '../utils/getStateAtTime';
2
+ import { IS_IOS, IS_WEBKIT } from '../utils/device';
3
+ import { modulo, moduloDiff } from '../utils/modulo';
2
4
  const getPath = (url) => {
3
5
  try {
4
6
  const { pathname } = new URL(url, window.location.href);
@@ -52,7 +54,7 @@ export class MediaClipManager {
52
54
  clearTimeout(this.timeout);
53
55
  if (this.isConnected()) {
54
56
  this.update();
55
- this.timeout = setTimeout(this.loop, INNER_TARGET_SYNC_THRESHOLD_MS);
57
+ this.timeout = setTimeout(this.loop, SYNC_INNER_TARGET_THRESHOLD_MS);
56
58
  }
57
59
  else {
58
60
  this.destroy();
@@ -90,6 +92,10 @@ export function assertElement(mediaElement, parentElement, clip, constructAssetU
90
92
  if (!element) {
91
93
  element = preloader.getElement(clip.file, clip.type);
92
94
  }
95
+ // Required for iOS
96
+ if (element instanceof HTMLVideoElement && !element.playsInline) {
97
+ element.playsInline = true;
98
+ }
93
99
  break;
94
100
  }
95
101
  }
@@ -124,121 +130,209 @@ export function assertVisualProperties(mediaElement, properties, objectFit) {
124
130
  */
125
131
  export function assertAudialProperties(mediaElement, properties, sinkId, surfaceVolume) {
126
132
  const clipVolume = properties.volume * surfaceVolume;
127
- if (mediaElement.volume !== clipVolume) {
128
- mediaElement.volume = clipVolume;
133
+ if (IS_IOS) {
134
+ // For iOS devices HTMLMediaElement.volume is readonly
135
+ // The best we can do is mute if the volume should be 0
136
+ if (clipVolume === 0 && !mediaElement.muted) {
137
+ mediaElement.muted = true;
138
+ }
139
+ else if (clipVolume > 0 && mediaElement.muted) {
140
+ mediaElement.muted = false;
141
+ }
129
142
  }
130
- if (mediaElement.sinkId !== sinkId) {
131
- try {
132
- mediaElement.setSinkId(sinkId).catch(() => {
133
- /* Do nothing, will be tried in next loop */
134
- });
135
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
143
+ else {
144
+ if (mediaElement.muted) {
145
+ mediaElement.muted = false;
136
146
  }
137
- catch (_) {
138
- /* Do nothing, will be tried in next loop */
147
+ if (mediaElement.volume !== clipVolume) {
148
+ mediaElement.volume = clipVolume;
149
+ }
150
+ if (mediaElement.sinkId !== sinkId) {
151
+ try {
152
+ mediaElement.setSinkId(sinkId).catch(() => {
153
+ /* Do nothing, will be tried in next loop */
154
+ });
155
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
156
+ }
157
+ catch (_) {
158
+ /* Do nothing, will be tried in next loop */
159
+ }
139
160
  }
140
161
  }
141
162
  }
142
- const OUTER_TARGET_SYNC_THRESHOLD_MS = 50; // When outside of this range we attempt to sync playback
143
- const OUTER_TARGET_SYNC_NO_PLAYBACK_RATE_ADJUSTMENT_THRESHOLD_MS = 500; // When outside of this range we attempt to sync playback (when playback rate adjustment not allowed)
144
- const INNER_TARGET_SYNC_THRESHOLD_MS = 5; // When attempting to sync playback, we aim for this accuracy
145
- const MAX_SYNC_THRESHOLD_MS = 1_000; // If we are further than this, we will seek instead
146
- const SEEK_LOOKAHEAD_MS = 5; // If it takes time to seek, we should seek ahead a little
163
+ /*
164
+ * When playbackRate adjustment is disabled (no-sync) we will attempt to seek-ahead, then wait to play.
165
+ * - This is a recovery situation, so we only do it when 2s out of sync. (outer threshold)
166
+ * - We seek 1s into the future to allow a lot of buffering time (lookahead)
167
+ * - We press play 0.1s early (inner threshold)
168
+ */
169
+ const NO_SYNC_SEEK_AHEAD_OUTER_THRESHOLD_MS = 2_000;
170
+ const NO_SYNC_SEEK_LOOKAHEAD_MS = 1_000;
171
+ const NO_SYNC_SEEK_AHEAD_INNER_THRESHOLD_MS = 100;
172
+ /**
173
+ * When playbackRate adjustment is enabled (sync) we will attempt to speed ramp to get closer to the correct time.
174
+ * - Whenever we are out of sync by an amount we think we can improve (outer threshold)
175
+ * - We adjust the playbackRate until we are close enough (inner threshold)
176
+ * - If we're too far away that it would take too long to sync (max threshold), then we seek instead.
177
+ * - If we seek ahead we may as well attempt to add a little time for buffering (lookahead)
178
+ */
179
+ const SYNC_OUTER_TARGET_THRESHOLD_MS = 50;
180
+ const SYNC_INNER_TARGET_THRESHOLD_MS = 5;
181
+ const SYNC_MAX_THRESHOLD_MS = 1_000;
182
+ const SYNC_SEEK_LOOKAHEAD_MS = 10;
183
+ // If the media is scheduled to go back to the start close in time to the end of the video, we'll use the loop attribute.
147
184
  const LOOPING_EPSILON_MS = 5;
148
185
  const PLAYBACK_ADJUSTMENT_SMOOTHING = 0.3;
149
186
  const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.1;
150
187
  function playbackSmoothing(deltaTime) {
151
- return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / MAX_SYNC_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
188
+ return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / SYNC_MAX_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
152
189
  }
153
- /**
154
- * Makes sure the media is at the correct time and speed.
155
- * - If we fall slightly behind, we will lightly adjust the speed to catch up.
156
- * - If we are too far away to smoothly realign, we will seek to the correct time.
157
- */
158
- export function assertTemporalProperties(mediaElement, properties, keyframes, syncState, disablePlaybackRateAdjustment) {
159
- if (mediaElement.paused && properties.rate > 0) {
190
+ function assertPlaybackRate(mediaElement, playbackRate) {
191
+ if (mediaElement.playbackRate !== playbackRate) {
192
+ mediaElement.playbackRate = playbackRate;
193
+ }
194
+ // It's more responsive on chromium to set playbackRate to 0 instead of pausing.
195
+ // It also makes it more responsive to start again.
196
+ // On iOS it doesn't make a difference, so we may as well.
197
+ if (mediaElement.paused) {
160
198
  mediaElement.play().catch(() => {
161
- /* Do nothing, will be tried in next loop */
199
+ /* do nothing*/
162
200
  });
163
201
  }
202
+ }
203
+ /**
204
+ * Makes sure the media is at the correct time and speed.
205
+ * - Algorithms and constants defined above
206
+ */
207
+ export function assertTemporalProperties(mediaElement, properties, keyframes, syncState, enablePlaybackRateAdjustment) {
208
+ // On Webkit (using the simulator on safari and COGS mobile app on iOS) changes to currentTime and playbackRate are much less responsive.
209
+ // We make sure we only do lower frequency updates, and don't change playbackRate.
210
+ const playbackRateSync = enablePlaybackRateAdjustment && !IS_WEBKIT;
164
211
  // At the end of the media, is it set back to the start?
165
212
  // Sounds like looping to me!
213
+ let isLooping = false;
166
214
  if (mediaElement.duration) {
167
215
  const nextTemporalKeyframe = keyframes.filter(([t, kf]) => t > properties.t && (kf?.set?.t !== undefined || kf?.set?.rate !== undefined))[0];
168
216
  if (nextTemporalKeyframe?.[1]?.set?.t === 0) {
169
217
  const timeRemaining = (mediaElement.duration - properties.t) / properties.rate;
170
218
  const timeUntilKeyframe = nextTemporalKeyframe[0] - properties.t;
171
- const isLooping = Math.abs(timeRemaining - timeUntilKeyframe) <= LOOPING_EPSILON_MS;
219
+ isLooping = Math.abs(timeRemaining - timeUntilKeyframe) <= LOOPING_EPSILON_MS;
172
220
  if (mediaElement.loop !== isLooping) {
173
221
  mediaElement.loop = isLooping;
174
222
  }
175
223
  }
176
224
  }
177
225
  const currentTime = mediaElement.currentTime * 1000;
178
- const deltaTime = currentTime - properties.t;
226
+ const deltaTime = isLooping && mediaElement.duration !== undefined
227
+ ? moduloDiff(currentTime, properties.t, mediaElement.duration * 1000)
228
+ : currentTime - properties.t;
179
229
  const deltaTimeAbs = Math.abs(deltaTime);
180
230
  switch (true) {
181
- case syncState.state === 'idle' && properties.rate > 0 && deltaTimeAbs <= OUTER_TARGET_SYNC_THRESHOLD_MS:
182
- // We are on course:
183
- // - The video is within accepted latency of the server time
184
- // - The playback rate is aligned with the server rate
185
- if (mediaElement.playbackRate !== properties.rate) {
186
- mediaElement.playbackRate = properties.rate;
231
+ /**
232
+ * Seek ahead behavior
233
+ * When playbackRate adjustment is not enabled we will seek ahead and try to prepare to play.
234
+ * We'll make sure everything is buffered and ready, then wait until we're on time.
235
+ * We'll try to press play once and leave it to continue.
236
+ */
237
+ case !playbackRateSync && syncState.state === 'idle' && properties.rate > 0 && deltaTimeAbs > NO_SYNC_SEEK_AHEAD_OUTER_THRESHOLD_MS: {
238
+ const target = (properties.t + properties.rate * NO_SYNC_SEEK_LOOKAHEAD_MS) / 1000;
239
+ if (mediaElement.duration !== undefined && target > mediaElement.duration && !isLooping) {
240
+ // We're not looping, and this is past the end of the video
241
+ return { state: 'idle' };
187
242
  }
243
+ assertPlaybackRate(mediaElement, 0);
244
+ mediaElement.currentTime = isLooping ? modulo(target, mediaElement.duration * 1000) : target;
245
+ return { state: 'seeking-ahead' };
246
+ }
247
+ case syncState.state === 'seeking-ahead' && mediaElement.seeking === true:
248
+ return { state: 'seeking-ahead' };
249
+ case syncState.state === 'seeking-ahead' && mediaElement.seeking === false: {
250
+ assertPlaybackRate(mediaElement, 0);
251
+ return { state: 'seeked-ahead' };
252
+ }
253
+ case syncState.state === 'seeked-ahead' && deltaTime < -NO_SYNC_SEEK_AHEAD_INNER_THRESHOLD_MS: {
254
+ assertPlaybackRate(mediaElement, properties.rate);
255
+ console.warn('Failed to seek ahead in time');
188
256
  return { state: 'idle' };
189
- case syncState.state === 'idle' &&
190
- properties.rate > 0 &&
191
- disablePlaybackRateAdjustment === true &&
192
- deltaTimeAbs <= OUTER_TARGET_SYNC_NO_PLAYBACK_RATE_ADJUSTMENT_THRESHOLD_MS:
193
- // If we aren't able to adjust playback rate, we are more forgiving
194
- // in our "synced" check to avoid the clip being seeked forward
195
- // in normal playback where we expect to be a little out of sync
196
- // due to network and startup latency
197
- if (mediaElement.playbackRate !== properties.rate) {
198
- mediaElement.playbackRate = properties.rate;
199
- }
257
+ }
258
+ case syncState.state === 'seeked-ahead' && deltaTimeAbs <= NO_SYNC_SEEK_AHEAD_INNER_THRESHOLD_MS: {
259
+ assertPlaybackRate(mediaElement, properties.rate);
260
+ return { state: 'idle' };
261
+ }
262
+ case syncState.state === 'seeked-ahead' && deltaTimeAbs > NO_SYNC_SEEK_AHEAD_OUTER_THRESHOLD_MS * 1.5: {
263
+ // This is an escape mechanism for this behavior. This may happen if the state changes after we've seeked ahead.
264
+ console.warn('Failed to seek ahead');
200
265
  return { state: 'idle' };
201
- case syncState.state === 'idle' &&
202
- disablePlaybackRateAdjustment !== true && // Never adjust playback rate if disabled for this clip
266
+ }
267
+ case syncState.state === 'seeked-ahead':
268
+ return { state: 'seeked-ahead' };
269
+ /**
270
+ * Time synchronization behavior
271
+ * When playbackRate adjustment is enabled we will address small deviations in time by ramping speed up and down.
272
+ * We address larger deviations with a seek, hoping to land close enough so we can finely adjust with playbackRate.
273
+ */
274
+ // Start intercept
275
+ case playbackRateSync &&
276
+ syncState.state === 'idle' &&
203
277
  properties.rate > 0 &&
204
- deltaTimeAbs > OUTER_TARGET_SYNC_THRESHOLD_MS &&
205
- deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS:
278
+ deltaTimeAbs > SYNC_OUTER_TARGET_THRESHOLD_MS &&
279
+ deltaTimeAbs <= SYNC_MAX_THRESHOLD_MS:
206
280
  {
207
- // We are close, we can smoothly adjust with playbackRate:
208
- // - The video must be playing
209
- // - We must be close in time to the server time
210
281
  const playbackRateAdjustment = playbackSmoothing(deltaTime);
211
282
  const adjustedPlaybackRate = Math.max(0, properties.rate - playbackRateAdjustment);
212
- if (mediaElement.playbackRate !== adjustedPlaybackRate) {
213
- mediaElement.playbackRate = adjustedPlaybackRate;
214
- }
283
+ assertPlaybackRate(mediaElement, adjustedPlaybackRate);
215
284
  return { state: 'intercepting' };
216
285
  }
217
- case syncState.state === 'intercepting' && properties.rate > 0 && deltaTimeAbs <= INNER_TARGET_SYNC_THRESHOLD_MS: {
218
- // We have intercepted, we can now play normally
219
- if (mediaElement.playbackRate !== properties.rate) {
220
- mediaElement.playbackRate = properties.rate;
221
- }
286
+ // Perfectly intercepted
287
+ case syncState.state === 'intercepting' && deltaTimeAbs <= SYNC_INNER_TARGET_THRESHOLD_MS: {
288
+ assertPlaybackRate(mediaElement, properties.rate);
222
289
  return { state: 'idle' };
223
290
  }
291
+ // Intercept went too far
224
292
  case syncState.state === 'intercepting' && Math.sign(deltaTime) === Math.sign(mediaElement.playbackRate - properties.rate): {
225
- // We have missed our interception. Go back to idle to try again.
293
+ assertPlaybackRate(mediaElement, properties.rate);
226
294
  return { state: 'idle' };
227
295
  }
228
- case syncState.state === 'intercepting':
296
+ // We're still on course
297
+ case syncState.state === 'intercepting' && deltaTimeAbs < SYNC_MAX_THRESHOLD_MS * 2:
229
298
  return { state: 'intercepting' };
230
- case syncState.state === 'seeking': {
299
+ // We're way off track
300
+ case syncState.state === 'intercepting':
301
+ assertPlaybackRate(mediaElement, properties.rate);
302
+ return { state: 'idle' };
303
+ /**
304
+ * Time synchronization behavior
305
+ * When playbackRate adjustment is enabled we will address small deviations in time by ramping speed up and down.
306
+ * We address larger deviations with a seek, hoping to land close enough so we can finely adjust with playbackRate.
307
+ */
308
+ case playbackRateSync && syncState.state === 'idle' && deltaTimeAbs > SYNC_MAX_THRESHOLD_MS: {
309
+ const seekTarget = (properties.t + properties.rate * SYNC_SEEK_LOOKAHEAD_MS) / 1000;
310
+ mediaElement.currentTime = isLooping ? modulo(seekTarget, mediaElement.duration * 1000) : seekTarget;
311
+ assertPlaybackRate(mediaElement, properties.rate);
231
312
  return { state: 'seeking' };
232
313
  }
233
- default: {
234
- // We cannot smoothly recover:
235
- // - We seek just ahead of server time
236
- if (mediaElement.playbackRate !== properties.rate) {
237
- mediaElement.playbackRate = properties.rate;
238
- }
239
- mediaElement.currentTime = (properties.t + properties.rate * SEEK_LOOKAHEAD_MS) / 1000;
314
+ case syncState.state === 'seeking' && mediaElement.seeking: {
240
315
  return { state: 'seeking' };
241
316
  }
317
+ case syncState.state === 'seeking' && !mediaElement.seeking: {
318
+ return { state: 'idle' };
319
+ }
320
+ /**
321
+ * Idle behavior
322
+ */
323
+ case syncState.state === 'idle':
324
+ assertPlaybackRate(mediaElement, properties.rate);
325
+ if (properties.rate === 0 && deltaTimeAbs > SYNC_OUTER_TARGET_THRESHOLD_MS) {
326
+ mediaElement.currentTime = properties.t / 1000;
327
+ }
328
+ return { state: 'idle' };
329
+ /**
330
+ * If none of the above conditions are met, we should exit the behavior.
331
+ * For example: we are intercepting but the media has now been paused
332
+ */
333
+ default: {
334
+ return { state: 'idle' };
335
+ }
242
336
  }
243
337
  }
244
338
  export class ImageManager extends MediaClipManager {
@@ -279,12 +373,7 @@ export class AudioManager extends MediaClipManager {
279
373
  return;
280
374
  const sinkId = this.getAudioOutput(this._state.audioOutput);
281
375
  assertAudialProperties(this.audioElement, currentState, sinkId, this.volume);
282
- const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState, this._state.disablePlaybackRateAdjustment);
283
- if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
284
- this.audioElement.addEventListener('seeked', () => {
285
- this.syncState = { state: 'idle' };
286
- }, { passive: true, once: true });
287
- }
376
+ const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState, this._state.enablePlaybackRateAdjustment);
288
377
  this.syncState = nextSyncState;
289
378
  }
290
379
  destroy() {
@@ -315,12 +404,7 @@ export class VideoManager extends MediaClipManager {
315
404
  const sinkId = this.getAudioOutput(this._state.audioOutput);
316
405
  assertVisualProperties(this.videoElement, currentState, this._state.fit);
317
406
  assertAudialProperties(this.videoElement, currentState, sinkId, this.volume);
318
- const nextSyncState = assertTemporalProperties(this.videoElement, currentState, this._state.keyframes, this.syncState, this._state.disablePlaybackRateAdjustment);
319
- if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
320
- this.videoElement.addEventListener('seeked', () => {
321
- this.syncState = { state: 'idle' };
322
- }, { passive: true, once: true });
323
- }
407
+ const nextSyncState = assertTemporalProperties(this.videoElement, currentState, this._state.keyframes, this.syncState, this._state.enablePlaybackRateAdjustment);
324
408
  this.syncState = nextSyncState;
325
409
  }
326
410
  destroy() {
@@ -24,12 +24,14 @@ declare const AudioMetadata: z.ZodObject<{
24
24
  type: z.ZodLiteral<"audio">;
25
25
  file: z.ZodString;
26
26
  audioOutput: z.ZodString;
27
+ enablePlaybackRateAdjustment: z.ZodBoolean;
27
28
  }, z.core.$strip>;
28
29
  export type VideoMetadata = z.infer<typeof VideoMetadata>;
29
30
  declare const VideoMetadata: z.ZodObject<{
30
31
  type: z.ZodLiteral<"video">;
31
32
  file: z.ZodString;
32
33
  audioOutput: z.ZodString;
34
+ enablePlaybackRateAdjustment: z.ZodBoolean;
33
35
  fit: z.ZodUnion<readonly [z.ZodLiteral<"contain">, z.ZodLiteral<"cover">, z.ZodLiteral<"none">]>;
34
36
  }, z.core.$strip>;
35
37
  export type NullKeyframe = z.infer<typeof NullKeyframe>;
@@ -153,6 +155,7 @@ export declare const MediaSurfaceStateSchema: z.ZodRecord<z.ZodString, z.ZodUnio
153
155
  type: z.ZodLiteral<"audio">;
154
156
  file: z.ZodString;
155
157
  audioOutput: z.ZodString;
158
+ enablePlaybackRateAdjustment: z.ZodBoolean;
156
159
  }, z.core.$strip>, z.ZodObject<{
157
160
  keyframes: z.ZodTuple<[z.ZodTuple<[z.ZodNumber, z.ZodObject<{
158
161
  set: z.ZodOptional<z.ZodObject<{
@@ -179,6 +182,7 @@ export declare const MediaSurfaceStateSchema: z.ZodRecord<z.ZodString, z.ZodUnio
179
182
  type: z.ZodLiteral<"video">;
180
183
  file: z.ZodString;
181
184
  audioOutput: z.ZodString;
185
+ enablePlaybackRateAdjustment: z.ZodBoolean;
182
186
  fit: z.ZodUnion<readonly [z.ZodLiteral<"contain">, z.ZodLiteral<"cover">, z.ZodLiteral<"none">]>;
183
187
  }, z.core.$strip>]>>;
184
188
  export type ImageOptions = VisualProperties;
@@ -194,7 +198,7 @@ export type AudioState = {
194
198
  type: 'audio';
195
199
  file: string;
196
200
  audioOutput: string;
197
- disablePlaybackRateAdjustment?: boolean;
201
+ enablePlaybackRateAdjustment: boolean;
198
202
  keyframes: [InitialAudioKeyframe, ...Array<AudioKeyframe | NullKeyframe>];
199
203
  };
200
204
  export type VideoState = {
@@ -202,7 +206,7 @@ export type VideoState = {
202
206
  file: string;
203
207
  fit: 'cover' | 'contain' | 'none';
204
208
  audioOutput: string;
205
- disablePlaybackRateAdjustment?: boolean;
209
+ enablePlaybackRateAdjustment: boolean;
206
210
  keyframes: [InitialVideoKeyframe, ...Array<VideoKeyframe | NullKeyframe>];
207
211
  };
208
212
  export type MediaClipState = ImageState | AudioState | VideoState;
@@ -19,11 +19,13 @@ const AudioMetadata = z.object({
19
19
  type: z.literal('audio'),
20
20
  file: z.string(),
21
21
  audioOutput: z.string(),
22
+ enablePlaybackRateAdjustment: z.boolean(),
22
23
  });
23
24
  const VideoMetadata = z.object({
24
25
  type: z.literal('video'),
25
26
  file: z.string(),
26
27
  audioOutput: z.string(),
28
+ enablePlaybackRateAdjustment: z.boolean(),
27
29
  fit: z.union([z.literal('contain'), z.literal('cover'), z.literal('none')]),
28
30
  });
29
31
  const NullKeyframe = z.tuple([z.number(), z.null()]);
@@ -0,0 +1,2 @@
1
+ export declare const IS_IOS: boolean;
2
+ export declare const IS_WEBKIT: boolean;
@@ -0,0 +1,4 @@
1
+ // Check an iOS-only property (See https://developer.mozilla.org/en-US/docs/Web/API/Navigator#non-standard_properties)
2
+ export const IS_IOS = typeof navigator !== 'undefined' && typeof navigator.standalone !== 'undefined';
3
+ // https://evilmartians.com/chronicles/how-to-detect-safari-and-ios-versions-with-ease
4
+ export const IS_WEBKIT = 'GestureEvent' in window;
@@ -1,5 +1,15 @@
1
- import { MediaClipState, TemporalProperties } from '../types/MediaSchema';
2
- export declare function getStateAtTime<State extends MediaClipState>(state: State, time: number): State['keyframes'][0][1]['set'] | undefined;
1
+ import { AudioState, ImageState, TemporalProperties, VideoState } from '../types/MediaSchema';
2
+ export type MinimalClipState = {
3
+ type: 'image';
4
+ keyframes: ImageState['keyframes'];
5
+ } | {
6
+ type: 'audio';
7
+ keyframes: AudioState['keyframes'];
8
+ } | {
9
+ type: 'video';
10
+ keyframes: VideoState['keyframes'];
11
+ };
12
+ export declare function getStateAtTime<State extends MinimalClipState>(state: State, time: number): State['keyframes'][0][1]['set'] | undefined;
3
13
  /**
4
14
  * Goes through all keyframes to lerp between many different properties
5
15
  * Note: This has no specific logic regarding types of properties
@@ -1,4 +1,4 @@
1
- import { defaultAudioOptions, defaultImageOptions, defaultVideoOptions } from '../types/MediaSchema';
1
+ import { defaultAudioOptions, defaultImageOptions, defaultVideoOptions, } from '../types/MediaSchema';
2
2
  export function getStateAtTime(state, time) {
3
3
  //If there are any null keyframes the clip has been terminated
4
4
  const nullKeyframes = state.keyframes.filter((kf) => kf[1] === null);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Correct modulo operator
3
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Remainder#description
4
+ */
5
+ export declare function modulo(n: number, divisor: number): number;
6
+ export declare function moduloDiff(n: number, m: number, divisor: number): number;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Correct modulo operator
3
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Remainder#description
4
+ */
5
+ export function modulo(n, divisor) {
6
+ return ((n % divisor) + divisor) % divisor;
7
+ }
8
+ export function moduloDiff(n, m, divisor) {
9
+ n = modulo(n, divisor);
10
+ m = modulo(m, divisor);
11
+ if (Math.abs(n - m) < divisor / 2) {
12
+ return n - m;
13
+ }
14
+ else {
15
+ return n < m ? n + divisor - m : n - (m + divisor);
16
+ }
17
+ }
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.16",
6
+ "version": "3.0.0-alpha.17",
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.16",
40
+ "@clockworkdog/timesync": "^3.0.0-alpha.17",
41
41
  "reconnecting-websocket": "^4.4.0",
42
42
  "zod": "^4.1.13"
43
43
  },