From d78f127e6ca64868d653f1318b2b80ffe4b0048b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20M=C3=A5rtensson?= Date: Thu, 20 Aug 2026 15:59:18 +0200 Subject: [PATCH] Fix playback starvation on long sequences at high speed Above PlayService.sequenceSpeed the graph switches to GraphMode.Sequence, where play() gated image prefetching on cacheSequenceImages$. That resolves only once every image in the sequence has its metadata cached, so on long sequences the request window never runs and playback falls back to appending one image per round trip - making a higher speed setting slower than a lower one. Gate on cacheSequence$ instead and run cacheSequenceImages$ alongside it, so the batching optimization no longer blocks playback. Also resolve sequence positions through a Map rather than per-frame indexOf scans, and skip the request window while the trajectory is still in another sequence, where indexOf returned -1 and images were requested from the start of the sequence. --- src/viewer/PlayService.ts | 60 +- test/viewer/PlayService.test.ts | 1136 ++++++------------------------- 2 files changed, 271 insertions(+), 925 deletions(-) diff --git a/src/viewer/PlayService.ts b/src/viewer/PlayService.ts index 6cbbcd769..798ba2a00 100644 --- a/src/viewer/PlayService.ts +++ b/src/viewer/PlayService.ts @@ -2,6 +2,7 @@ import { combineLatest as observableCombineLatest, empty as observableEmpty, from as observableFrom, + merge as observableMerge, of as observableOf, zip as observableZip, Observable, @@ -160,9 +161,8 @@ export class PlayService { return observableOf<[Sequence, NavigationDirection]>([undefined, direction]); } - const sequence$: Observable = (mode === GraphMode.Sequence ? - this._graphService.cacheSequenceImages$(sequenceId, imageId) : - this._graphService.cacheSequence$(sequenceId)).pipe( + const sequence$: Observable = this._graphService + .cacheSequence$(sequenceId).pipe( retry(3), catchError( (error: Error): Observable => { @@ -171,8 +171,29 @@ export class PlayService { return observableOf(undefined); })); + // Caching all images of the sequence is a batching + // optimization for the sequence graph mode. It must + // run alongside image caching instead of gating it: + // it only completes once every batch has been + // retrieved, which for long sequences takes long + // enough to starve playback of images while running. + const sequenceImages$: Observable = + mode === GraphMode.Sequence ? + this._graphService + .cacheSequenceImages$(sequenceId, imageId) + .pipe( + retry(3), + catchError( + (error: Error): Observable => { + console.error(error); + + return observableEmpty(); + })) : + observableEmpty(); + return observableCombineLatest( - sequence$, + observableMerge(sequence$, sequenceImages$).pipe( + distinctUntilChanged()), observableOf(direction)); }), switchMap( @@ -186,6 +207,21 @@ export class PlayService { imageIds.reverse(); } + // Positions are resolved on every animation frame, + // linear lookups would scale with the sequence length. + const imageIndices: Map = + new Map(); + for (let i: number = 0; i < imageIds.length; i++) { + imageIndices.set(imageIds[i], i); + } + + const indexOf: (id: string) => number = + (id: string): number => { + const index: number = imageIndices.get(id); + + return index === undefined ? -1 : index; + }; + return this._stateService.currentState$.pipe( map( (frame: AnimationFrame): [string, number] => { @@ -197,8 +233,12 @@ export class PlayService { [lastTrajectoryKey, imagesAhead]: [string, number]): [string, string[]] => { - if (lastRequestKey === undefined) { - lastRequestKey = lastTrajectoryKey; + // The trajectory reaches beyond the + // sequence when traversing into another + // one, there is nothing to request here. + const current: number = indexOf(lastTrajectoryKey); + if (current === -1) { + return [lastRequestKey, []]; } const lastIndex: number = imageIds.length - 1; @@ -206,8 +246,12 @@ export class PlayService { return [lastRequestKey, []]; } - const current: number = imageIds.indexOf(lastTrajectoryKey); - const start: number = imageIds.indexOf(lastRequestKey) + 1; + const lastRequestIndex: number = + lastRequestKey === undefined ? + -1 : indexOf(lastRequestKey); + const start: number = + (lastRequestIndex === -1 ? + current : lastRequestIndex) + 1; const end: number = Math.min(lastIndex, current + this._imagesAhead - imagesAhead) + 1; if (end <= start) { diff --git a/test/viewer/PlayService.test.ts b/test/viewer/PlayService.test.ts index 14f381e4f..199510561 100644 --- a/test/viewer/PlayService.test.ts +++ b/test/viewer/PlayService.test.ts @@ -1,977 +1,279 @@ -import { bootstrap } from "../Bootstrap"; -bootstrap(); - import { - of as observableOf, - zip as observableZip, + BehaviorSubject, Observable, + of as observableOf, Subject, } from "rxjs"; -import { take, first, skip } from "rxjs/operators"; -import { MockCreator } from "../helper/MockCreator"; -import { ImageHelper } from "../helper/ImageHelper"; -import { StateServiceMockCreator } from "../helper/StateServiceMockCreator"; -import { FrameHelper } from "../helper/FrameHelper"; -import { Image } from "../../src/graph/Image"; -import { APIWrapper } from "../../src/api/APIWrapper"; -import { ImageEnt } from "../../src/api/ents/ImageEnt"; -import { Graph } from "../../src/graph/Graph"; + import { GraphMode } from "../../src/graph/GraphMode"; import { GraphService } from "../../src/graph/GraphService"; -import { NavigationEdgeStatus } from "../../src/graph/interfaces/NavigationEdgeStatus"; -import { ImageCache } from "../../src/graph/ImageCache"; +import { Image } from "../../src/graph/Image"; import { Sequence } from "../../src/graph/Sequence"; -import { IAnimationState } from "../../src/state/interfaces/IAnimationState"; -import { AnimationFrame } from "../../src/state/interfaces/AnimationFrame"; +import { NavigationDirection } from "../../src/graph/edge/NavigationDirection"; +import { NavigationEdgeStatus } from "../../src/graph/interfaces/NavigationEdgeStatus"; import { State } from "../../src/state/State"; import { StateService } from "../../src/state/StateService"; +import { AnimationFrame } from "../../src/state/interfaces/AnimationFrame"; +import { IAnimationState } from "../../src/state/interfaces/IAnimationState"; import { PlayService } from "../../src/viewer/PlayService"; -import { NavigationDirection } from "../../src/graph/edge/NavigationDirection"; -import { DataProvider } from "../helper/ProviderHelper"; -import { S2GeometryProvider } from "../../src/api/S2GeometryProvider"; - -describe("PlayService.ctor", () => { - it("should be defined when constructed", () => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - expect(playService).toBeDefined(); - }); - - it("should emit default values", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - observableZip( - playService.direction$, - playService.playing$, - playService.speed$).pipe( - first()) - .subscribe( - ([d, p, s]: [NavigationDirection, boolean, number]): void => { - expect(d).toBe(NavigationDirection.Next); - expect(p).toBe(false); - expect(s).toBe(0.5); - - done(); - }); - }); -}); - -describe("PlayService.playing", () => { - it("should be playing after calling play", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - playService.play(); - - expect(playService.playing).toBe(true); - - playService.playing$ - .subscribe( - (playing: boolean): void => { - expect(playing).toBe(true); - - done(); - }); - }); - it("should not be playing after calling stop", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); +/** + * Edge targets are deliberately outside the sequence so that the images the + * trajectory append loop requests can be told apart from the ones the + * prefetch window requests. + */ +const EDGE_SUFFIX: string = "__edge"; - const playService: PlayService = new PlayService(graphService, stateService); +function createEdgeStatus(id: string): NavigationEdgeStatus { + const target: string = `${id}${EDGE_SUFFIX}`; - playService.play(); - - const setGraphModeSpy: jasmine.Spy = spyOn(graphService, "setGraphMode").and.stub(); - const cutImagesSpy: jasmine.Spy = spyOn(stateService, "cutImages").and.stub(); - const setSpeedSpy: jasmine.Spy = spyOn(stateService, "setSpeed").and.stub(); - - playService.stop(); - - expect(setGraphModeSpy.calls.count()).toBe(1); - expect(setGraphModeSpy.calls.argsFor(0)[0]).toBe(GraphMode.Spatial); - - expect(cutImagesSpy.calls.count()).toBe(1); - - expect(setSpeedSpy.calls.count()).toBe(1); - expect(setSpeedSpy.calls.argsFor(0)[0]).toBe(1); - - expect(playService.playing).toBe(false); - - playService.playing$ - .subscribe( - (playing: boolean): void => { - expect(playing).toBe(false); - - done(); - }); - }); -}); - -describe("PlayService.speed$", () => { - it("should emit when changing speed", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - playService.speed$.pipe( - skip(1)) - .subscribe( - (speed: number): void => { - expect(speed).toBe(0); - - done(); - }); - - playService.setSpeed(0); - }); - - it("should not emit when setting current speed", () => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - playService.setSpeed(1); - - let speedEmitCount: number = 0; - let firstEmit: boolean = true; - playService.speed$.pipe( - skip(1)) - .subscribe( - (speed: number): void => { - speedEmitCount++; - - if (firstEmit) { - expect(speed).toBe(0); - firstEmit = false; - } else { - expect(speed).toBe(1); - } - }); - - playService.setSpeed(0); - playService.setSpeed(0); - playService.setSpeed(1); - playService.setSpeed(1); - - expect(speedEmitCount).toBe(2); - }); - - it("should clamp speed values to 0, 1 interval", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - let firstEmit: boolean = true; - playService.speed$.pipe( - skip(1)) - .subscribe( - (speed: number): void => { - if (firstEmit) { - expect(speed).toBe(0); - firstEmit = false; - } else { - expect(speed).toBe(1); - - done(); - } - }); - - playService.setSpeed(-1); - playService.setSpeed(2); - }); -}); - -let createState: () => IAnimationState = (): IAnimationState => { return { - alpha: 0, - camera: null, - currentCamera: null, - currentIndex: 0, - currentImage: null, - currentTransform: null, - lastImage: null, - motionless: false, - imagesAhead: 0, - previousCamera: null, - previousImage: null, - previousTransform: null, - reference: null, - state: State.Traversing, - stateTransitionAlpha: 0, - trajectory: null, - zoom: 0, + cached: true, + edges: [ + { + data: { + direction: NavigationDirection.Next, + worldMotionAzimuth: 0, + }, + source: id, + target, + }, + { + data: { + direction: NavigationDirection.Prev, + worldMotionAzimuth: 0, + }, + source: id, + target, + }, + ], + }; +} + +function createImage(id: string, sequenceId: string): Image { + return { + assetsCached: true, + capturedAt: 0, + id: id, + sequenceEdges$: observableOf(createEdgeStatus(id)), + sequenceId: sequenceId, + spatialEdges$: observableOf(createEdgeStatus(id)), + }; +} + +function createSequence(sequenceId: string, count: number): Sequence { + const imageIds: string[] = []; + for (let i: number = 0; i < count; i++) { + imageIds.push(`${sequenceId}_${i}`); + } + + return new Sequence({ id: sequenceId, image_ids: imageIds }); +} + +interface Harness { + cacheSequence$: Subject; + cacheSequenceImages$: Subject; + cacheSequenceImagesSpy: jest.Mock; + currentImage$: Subject; + frame: (lastId: string, imagesAhead: number) => void; + graphMode$: BehaviorSubject; + playService: PlayService; + prefetched: () => string[]; +} + +function createHarness(): Harness { + const currentImage$: Subject = new Subject(); + const currentState$: Subject = new Subject(); + const state$: BehaviorSubject = + new BehaviorSubject(State.Traversing); + + const cacheSequenceSubject$: Subject = new Subject(); + const cacheSequenceImagesSubject$: Subject = + new Subject(); + + const graphMode$: BehaviorSubject = + new BehaviorSubject(GraphMode.Spatial); + + const requestedIds: string[] = []; + + const cacheSequenceImagesSpy: jest.Mock = jest.fn( + (): Observable => cacheSequenceImagesSubject$); + + const graphService: unknown = { + cacheImage$: (id: string): Observable => { + requestedIds.push(id); + + return observableOf(createImage(id, "seq")); + }, + cacheSequence$: (): Observable => cacheSequenceSubject$, + cacheSequenceImages$: cacheSequenceImagesSpy, + graphMode$: graphMode$, + setGraphMode: (mode: GraphMode): void => { graphMode$.next(mode); }, }; -}; - -describe("PlayService.play", () => { - let imageHelper: ImageHelper; - - let api: APIWrapper; - let graphService: GraphService; - let stateService: StateService; - - beforeEach(() => { - imageHelper = new ImageHelper(); - - api = new APIWrapper(new DataProvider()); - graphService = new GraphService(new Graph(api)); - stateService = new StateServiceMockCreator().create(); - }); - - it("should set graph mode when passing speed threshold", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const setGraphModeSpy: jasmine.Spy = spyOn(graphService, "setGraphMode").and.stub(); - - playService.setSpeed(0); - - playService.play(); - - playService.setSpeed(1); - playService.setSpeed(0); - - expect(setGraphModeSpy.calls.count()).toBe(3); - expect(setGraphModeSpy.calls.argsFor(0)[0]).toBe(GraphMode.Spatial); - expect(setGraphModeSpy.calls.argsFor(1)[0]).toBe(GraphMode.Sequence); - expect(setGraphModeSpy.calls.argsFor(2)[0]).toBe(GraphMode.Spatial); - }); - - it("should stop immediately if image does not have an edge in current direction and no bridge", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - frame.state.currentImage.initializeCache(new ImageCache(undefined)); - (>stateService.currentState$).next(frame); - - frame.state.currentImage.cacheSequenceEdges([]); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should stop if earth mode is emitted", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - (>stateService.state$).next(State.Earth); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should stop if error occurs", () => { - spyOn(console, "error").and.stub(); - - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - - const image: Image = frame.state.currentImage; - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - sequenceEdgesSubject.error(new Error()); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should emit in correct order if stopping immediately", (done: () => void) => { - const playService: PlayService = new PlayService(graphService, stateService); - - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - let firstEmit: boolean = true; - playService.playing$.pipe( - skip(1), - take(2)) - .subscribe( - (playing: boolean): void => { - expect(playing).toBe(playService.playing); - - if (firstEmit) { - expect(playing).toBe(true); - firstEmit = false; - } else { - expect(playing).toBe(false); - done(); - } - }); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - frame.state.currentImage.initializeCache(new ImageCache(undefined)); - (>stateService.currentState$).next(frame); - - frame.state.currentImage.cacheSequenceEdges([]); - }); - - it("should not stop if images are not cached", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - sequenceEdgesSubject.next({ cached: false, edges: [] }); - - expect(stopSpy.calls.count()).toBe(0); - - sequenceEdgesSubject.next({ cached: true, edges: [] }); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should stop if no more images", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - - const prevFullImage: ImageEnt = new ImageHelper().createImageEnt(); - prevFullImage.captured_at = -1; - const prevImage: Image = new Image(prevFullImage); - prevImage.makeComplete(prevFullImage); - frame.state.trajectory.splice(0, 0, prevImage); - frame.state.currentIndex = 1; - - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - sequenceEdgesSubject.next({ cached: false, edges: [] }); - - expect(stopSpy.calls.count()).toBe(0); - - sequenceEdgesSubject.next({ cached: true, edges: [] }); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should append image when cached", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const appendImagesSpy: jasmine.Spy = stateService.appendImagess; - appendImagesSpy.and.callThrough(); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - const fullToImage: ImageEnt = imageHelper.createImageEnt(); - fullToImage.id = "toKey"; - const toImage: Image = new Image(fullToImage); - - sequenceEdgesSubject.next({ - cached: true, - edges: [{ - data: { direction: NavigationDirection.Next, worldMotionAzimuth: 0 }, - source: image.id, - target: toImage.id, - }], - }); - - cacheImageSubject.next(toImage); - - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(toImage.id); - - expect(appendImagesSpy.calls.count()).toBe(1); - expect(appendImagesSpy.calls.argsFor(0)[0].length).toBe(1); - expect(appendImagesSpy.calls.argsFor(0)[0][0].id).toBe(toImage.id); - }); - - it("should stop on image caching error", () => { - spyOn(console, "error").and.stub(); - - const playService: PlayService = new PlayService(graphService, stateService); - - const appendImagesSpy: jasmine.Spy = stateService.appendImagess; - appendImagesSpy.and.callThrough(); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - (>stateService.currentState$).next(frame); + const stateService: unknown = { + appendImagess: (): void => { /*noop*/ }, + clearPriorImages: (): void => { /*noop*/ }, + currentImage$: currentImage$, + currentState$: currentState$, + cutImages: (): void => { /*noop*/ }, + setSpeed: (): void => { /*noop*/ }, + state$: state$, + }; - const fullToImage: ImageEnt = imageHelper.createImageEnt(); - fullToImage.id = "toKey"; - const toImage: Image = new Image(fullToImage); + const playService: PlayService = new PlayService( + graphService, + stateService); + + const frame = (lastId: string, imagesAhead: number): void => { + const lastImage: Image = createImage(lastId, "seq"); + const state: unknown = { + currentImage: lastImage, + currentIndex: 0, + imagesAhead: imagesAhead, + lastImage: lastImage, + trajectory: [lastImage], + }; + + currentState$.next({ fps: 60, id: 0, state: state }); + }; - sequenceEdgesSubject.next({ - cached: true, - edges: [{ - data: { direction: NavigationDirection.Next, worldMotionAzimuth: 0 }, - source: image.id, - target: toImage.id, - }], + const prefetched = (): string[] => + requestedIds.filter((id: string): boolean => { + return !id.endsWith(EDGE_SUFFIX); }); - cacheImageSubject.error(new Error()); - - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(toImage.id); - - expect(appendImagesSpy.calls.count()).toBe(0); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should cache sequence when in spatial graph mode", () => { - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - // Set speed to zero so that graph mode is set to spatial when calling play - playService.setSpeed(0); - - const cacheSequenceSpy: jasmine.Spy = spyOn(graphService, "cacheSequence$"); - cacheSequenceSpy.and.returnValue(new Subject()); - const cacheSequenceImagesSpy: jasmine.Spy = spyOn(graphService, "cacheSequenceImages$"); - cacheSequenceImagesSpy.and.returnValue(new Subject()); - - playService.play(); - - const currentImage: Image = imageHelper.createImage(); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - expect(cacheSequenceSpy.calls.count()).toBe(1); - expect(cacheSequenceSpy.calls.argsFor(0)[0]).toBe(currentImage.sequenceId); - - expect(cacheSequenceImagesSpy.calls.count()).toBe(0); - - playService.stop(); - }); - - it("should cache sequence images when in sequence graph mode", () => { - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - // Set speed to one so that graph mode is set to sequence when calling play - playService.setSpeed(1); - - const cacheSequenceSpy: jasmine.Spy = spyOn(graphService, "cacheSequence$"); - cacheSequenceSpy.and.returnValue(new Subject()); - const cacheSequenceImagesSpy: jasmine.Spy = spyOn(graphService, "cacheSequenceImages$"); - cacheSequenceImagesSpy.and.returnValue(new Subject()); + return { + cacheSequence$: cacheSequenceSubject$, + cacheSequenceImages$: cacheSequenceImagesSubject$, + cacheSequenceImagesSpy: cacheSequenceImagesSpy, + currentImage$: currentImage$, + frame: frame, + graphMode$: graphMode$, + playService: playService, + prefetched: prefetched, + }; +} - playService.play(); +describe("PlayService.play", () => { + it("should cache images before the sequence images request resolves", () => { + const harness: Harness = createHarness(); - const currentImage: Image = imageHelper.createImage(); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); + // Above PlayService.sequenceSpeed, so the graph mode is sequence. + harness.playService.setSpeed(1); + harness.playService.play(); - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); + expect(harness.graphMode$.value).toBe(GraphMode.Sequence); - expect(cacheSequenceSpy.calls.count()).toBe(0); + harness.currentImage$.next(createImage("seq_100", "seq")); - expect(cacheSequenceImagesSpy.calls.count()).toBe(1); - expect(cacheSequenceImagesSpy.calls.argsFor(0)[0]).toBe(currentImage.sequenceId); + // The sequence request resolves while the sequence images request is + // still in flight, as it is for seconds on a long sequence. + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - playService.stop(); + expect(harness.cacheSequenceImagesSpy).toHaveBeenCalledTimes(1); + expect(harness.prefetched().length).toBeGreaterThan(0); + expect(harness.prefetched()[0]).toBe("seq_101"); }); - it("should not pre-cache if current image is last sequence image", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; + it("should request a window of images ahead of the trajectory", () => { + const harness: Harness = createHarness(); - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); + harness.playService.setSpeed(1); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - const prevImageKey: string = "image1"; + // Images ahead is 50 at maximum speed. + expect(harness.prefetched().length).toBe(50); + expect(harness.prefetched()[0]).toBe("seq_101"); + expect(harness.prefetched()[49]).toBe("seq_150"); - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); + const requested: number = harness.prefetched().length; + harness.frame("seq_150", 50); - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [prevImageKey, currentImage.id] }); - cacheSequenceSubject.next(sequence); + expect(harness.prefetched().length).toBe(requested); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); + harness.frame("seq_150", 45); - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - (>stateService.currentState$).next({ fps: 60, id: 0, state: state }); - - expect(cacheImageSpy.calls.count()).toBe(0); - - playService.stop(); + expect(harness.prefetched().slice(requested)) + .toEqual([ + "seq_151", + "seq_152", + "seq_153", + "seq_154", + "seq_155", + ]); }); - it("should pre-cache one trajectory image", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; + it("should not request from the start of another sequence", () => { + const harness: Harness = createHarness(); - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); + // Spatial mode, so the request window is exercised on its own. + harness.playService.setSpeed(0.2); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); - const nextImageKey: string = "image1"; + harness.frame("other_7", 0); - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); + expect(harness.prefetched()).toEqual([]); - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id, nextImageKey] }); - cacheSequenceSubject.next(sequence); + harness.frame("seq_100", 0); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - cacheImageSubject.next(new ImageHelper().createImage()); - - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(nextImageKey); - - playService.stop(); + expect(harness.prefetched()[0]).toBe("seq_101"); }); - it("should pre-cache one trajectory image in prev direction", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Prev); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const prevImageKey: string = "image1"; - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [prevImageKey, currentImage.id] }); - cacheSequenceSubject.next(sequence); + it("should cache images when the sequence images request fails", () => { + const harness: Harness = createHarness(); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); + harness.playService.setSpeed(1); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; + const requested: number = harness.prefetched().length; + expect(requested).toBe(50); - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); + jest.spyOn(console, "error").mockImplementation((): void => { /*noop*/ }); + harness.cacheSequenceImages$.error(new Error("Failed")); - cacheImageSubject.next(new ImageHelper().createImage()); + harness.frame("seq_150", 45); - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(prevImageKey); - - // Sequence should not have changed because of internal reversing - expect(sequence.imageIds[0]).toBe(prevImageKey); - expect(sequence.imageIds[1]).toBe(currentImage.id); - - playService.stop(); + expect(harness.prefetched().length).toBeGreaterThan(requested); }); - it("should not pre-cache the same image twice", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - currentImage.makeComplete(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const nextImageKey: string = "image1"; - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id, nextImageKey] }); - cacheSequenceSubject.next(sequence); - - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - const nextFullImage: ImageEnt = new ImageHelper().createImageEnt(); - nextFullImage.sequence.id = sequenceKey; - nextFullImage.id = nextImageKey; - const nextImage: Image = new Image(nextFullImage); - nextImage.makeComplete(nextFullImage); - cacheImageSubject.next(nextImage); + it("should not request sequence images in spatial mode", () => { + const harness: Harness = createHarness(); - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(nextImageKey); + // Below PlayService.sequenceSpeed, so the graph mode is spatial. + harness.playService.setSpeed(0.2); + harness.playService.play(); - currentStateSubject$.next({ fps: 60, id: 0, state: state }); + expect(harness.graphMode$.value).toBe(GraphMode.Spatial); - expect(cacheImageSpy.calls.count()).toBe(1); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - playService.stop(); + expect(harness.cacheSequenceImagesSpy).not.toHaveBeenCalled(); + expect(harness.prefetched().length).toBeGreaterThan(0); }); - it("should not pre-cache if all sequence images in trajectory", () => { - graphService.setGraphMode(GraphMode.Spatial); + it("should reverse the request window when playing backwards", () => { + const harness: Harness = createHarness(); - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - currentImage.makeComplete(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const nextImageKey: string = "image1"; - const nextFullImage: ImageEnt = new ImageHelper().createImageEnt(); - nextFullImage.sequence.id = sequenceKey; - nextFullImage.id = nextImageKey; - const nextImage: Image = new Image(nextFullImage); - nextImage.makeComplete(nextFullImage); - new MockCreator().mockProperty(nextImage, "sequenceEdges$", new Subject()); - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id, nextImageKey] }); - cacheSequenceSubject.next(sequence); - - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage, nextImage]; - state.lastImage = nextImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - expect(cacheImageSpy.calls.count()).toBe(0); - - playService.stop(); - }); + harness.playService.setSpeed(1); + harness.playService.setDirection(NavigationDirection.Prev); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - it("should pre-cache up to specified images ahead", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - // Zero speed means max ten images ahead - playService.setSpeed(0); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "currentImageKey"; - const currentImage: Image = new Image(currentFullImage); - currentImage.makeComplete(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id] }); - const sequenceImages: Image[] = []; - - for (let i: number = 0; i < 20; i++) { - const sequenceImageKey: string = `image${i}`; - const sequenceFullImage: ImageEnt = new ImageHelper().createImageEnt(); - sequenceFullImage.sequence.id = sequenceKey; - sequenceFullImage.id = sequenceImageKey; - const sequenceImage: Image = new Image(sequenceFullImage); - sequenceImage.makeComplete(sequenceFullImage); - new MockCreator().mockProperty(sequenceImage, "sequenceEdges$", new Subject()); - - sequence.imageIds.push(sequenceImage.id); - sequenceImages.push(sequenceImage); - } - - const currentImageSubject: Subject = >stateService.currentImage$; - - currentImageSubject.next(currentImage); - cacheSequenceSubject.next(sequence); - - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$").and.callFake( - (key: string): Observable => { - const fullImage: ImageEnt = new ImageHelper().createImageEnt(); - fullImage.sequence.id = sequenceKey; - fullImage.id = key; - const image: Image = new Image(fullImage); - image.makeComplete(fullImage); - - return observableOf(image); - }); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.currentIndex = 0; - state.imagesAhead = 0; - - // Cache ten images immediately - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - let cachedCount: number = 10; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add one image to trajectory before current image has moved - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, 1)); - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 1; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Current image has moved one step in trajectory to the last image, images ahead - // is zero and one new image should be cached - state.currentIndex += 1; - state.currentImage = state.trajectory[state.currentIndex]; - state.imagesAhead = 0; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - cachedCount += 1; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add 5 images to trajectory and move current image 3 steps - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, 5)); - state.currentIndex += 3; - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 2; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // Three new images should be cached - cachedCount += 3; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add all 14 images cached so far to trajectory and move current image to last - // trajectory image - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, 8)); - state.currentIndex = state.trajectory.length - 1; - expect(state.currentIndex).toBe(14); - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 0; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // Six last images should be cached - cachedCount += 6; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add all remaining images to trajectory and move current image one step - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, sequenceImages.length)); - state.currentIndex += 1; - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 5; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Move current image to last trajectory image - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, sequenceImages.length)); - state.currentIndex = state.trajectory.length - 1; - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 0; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(20); - - for (let i: number = 0; i < 20; i++) { - expect(cacheImageSpy.calls.argsFor(i)[0]).toBe(sequence.imageIds[i + 1]); - } - - playService.stop(); + expect(harness.prefetched()[0]).toBe("seq_99"); + expect(harness.prefetched()[49]).toBe("seq_50"); }); });