diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 88b02de5..096725fb 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -9,6 +9,7 @@ import { applyKeyframeContent, } from '../index' import { baseInstances } from '../resolver/lib/instance' +import { clone } from '../resolver/lib/lib' describe('index', () => { test('resolve timeline', () => { @@ -783,4 +784,99 @@ describe('index', () => { }, }) }) + + test('Cache', () => { + const timeline0: TimelineObject[] = [ + { + id: 'bg', + enable: { + while: 1, + }, + layer: 'layerA', + content: {}, + }, + { + id: 'group0', + enable: { + start: 10000, + }, + layer: '', + content: {}, + children: [ + { + id: 'child0', + enable: { + start: 5000, // 15000 + end: null, + }, + layer: 'layerA', + content: {}, + }, + ], + isGroup: true, + }, + { + id: 'bg2', + enable: { + while: '#bg', + }, + layer: 'layerB', + content: {}, + priority: 1, + }, + { + id: 'bg3', + enable: { + while: 1, + }, + layer: 'layerB', + content: {}, + priority: 0, + }, + ] + + const timeline1 = clone(timeline0) + + // Nudge "group0" a little bit, this should cause "child0" to be resolved differently, which in turn affects "bg" via collision + timeline1[1].enable = { + start: 10001, + } + + const cache = {} + + resolveTimeline(timeline0, { cache: cache, time: 0 }) + + const rtl1NoCache = resolveTimeline(timeline1, { time: 0 }) + const rtl1 = resolveTimeline(timeline1, { cache: cache, time: 0 }) + + const state1NoCache = getResolvedState(rtl1NoCache, 10) + const state1 = getResolvedState(rtl1, 10) + + // cache and no-cache should render the same result + expect(state1NoCache.layers['layerA']).toBeTruthy() + + // "bg" should have changed, since that is affected by a collision with "child0" + expect(rtl1.objects['bg'].resolved.instances).toMatchObject([ + { + start: 0, + end: 15001, + }, + ]) + // "bg2" should have changed, since that is affected by the change to "bg" + expect(rtl1.objects['bg2'].resolved.instances).toMatchObject([ + { + start: 0, + end: 15001, + }, + ]) + // "bg3" should have changed, since that is affected by a collision with "bg2" + expect(rtl1.objects['bg3'].resolved.instances).toMatchObject([ + { + start: 15001, + end: null, + }, + ]) + + expect(state1.layers['layerA']).toEqual(state1NoCache.layers['layerA']) + }) }) diff --git a/src/__tests__/invalidate.spec.ts b/src/__tests__/invalidate.spec.ts index efc7598c..a1e37856 100644 --- a/src/__tests__/invalidate.spec.ts +++ b/src/__tests__/invalidate.spec.ts @@ -55,7 +55,7 @@ describeVariants( graphic1.enable.start = '#graphic0.end + 15' // 35 const resolved2 = resolveTimeline(timeline, { time: 0, cache }) - expect(resolved2.statistics.resolvingObjectCount).toEqual(1) + expect(resolved2.statistics.resolvingObjectCount).toEqual(2) expect(resolved2.objects['video'].resolved).toMatchObject({ instances: [{ start: 0, end: 100 }] }) expect(resolved2.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) expect(resolved2.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 35, end: 50 }] }) @@ -121,7 +121,7 @@ describeVariants( const resolved2 = resolveTimeline(timeline, { time: 0, cache }) - expect(resolved2.statistics.resolvingObjectCount).toEqual(2) + expect(resolved2.statistics.resolvingObjectCount).toEqual(3) expect(resolved2.objects['video0'].resolved.instances).toMatchObject([{ start: 20, end: 30 }]) expect(resolved2.objects['graphic0'].resolved.instances).toMatchObject([{ start: 30, end: 40 }]) expect(resolved2.objects['graphic1'].resolved.instances).toHaveLength(0) @@ -261,7 +261,7 @@ describeVariants( timeline.splice(index, 1) const resolved3 = resolveTimeline(timeline, { time: 0, cache }) - expect(resolved3.statistics.resolvingObjectCount).toEqual(1) + expect(resolved3.statistics.resolvingObjectCount).toEqual(2) expect(resolved3.objects['video0'].resolved).toMatchObject({ instances: [{ start: 0, end: 100 }] }) expect(resolved3.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) expect(resolved3.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 20, end: 25 }] }) diff --git a/src/__tests__/performance.spec.ts b/src/__tests__/performance.spec.ts index 68d04dff..1f0925ac 100644 --- a/src/__tests__/performance.spec.ts +++ b/src/__tests__/performance.spec.ts @@ -11,7 +11,7 @@ describe('performance', () => { test( 'performance test, no cache', () => { - const { sortedTimes, executionTimeAvg } = doPerformanceTest(TEST_COUNT, false) + const { sortedTimes, executionTimeAvg } = doPerformanceTest(TEST_COUNT, TIMEOUT_TIME, false) console.log( `No Cache: Average time of execution: ${round(executionTimeAvg)} ms\n` + 'Worst 5:\n' + @@ -29,7 +29,7 @@ describe('performance', () => { test( 'performance test, with cache', () => { - const { sortedTimes, executionTimeAvg } = doPerformanceTest(TEST_COUNT, true) + const { sortedTimes, executionTimeAvg } = doPerformanceTest(TEST_COUNT, TIMEOUT_TIME, true) console.log( `With cache: Average time of execution: ${round(executionTimeAvg)} ms\n` + 'Worst 5:\n' + diff --git a/src/__tests__/performance.ts b/src/__tests__/performance.ts index 3cc5ec01..748479d2 100644 --- a/src/__tests__/performance.ts +++ b/src/__tests__/performance.ts @@ -27,6 +27,7 @@ export const round = (num: number): number => { export const doPerformanceTest = ( testCount: number, + timeoutTime: number, useCache: boolean ): { errorCount: number @@ -48,8 +49,13 @@ export const doPerformanceTest = ( const testCountMax = testCount * 2 + const startTime = Date.now() for (let i = 0; i < testCountMax; i++) { if (executionTimeCount >= testCount) break + const totalDuration = Date.now() - startTime + if (totalDuration >= timeoutTime) { + throw new Error(`Tests took too long (${totalDuration}ms)`) + } seed++ diff --git a/src/resolver/CacheHandler.ts b/src/resolver/CacheHandler.ts index 3493b3a0..0dfd6292 100644 --- a/src/resolver/CacheHandler.ts +++ b/src/resolver/CacheHandler.ts @@ -2,11 +2,11 @@ import { Reference, ResolvedTimelineObject, ResolvedTimelineObjects, ResolverCac import { ResolvedTimelineHandler } from './ResolvedTimelineHandler' import { mapToObject } from './lib/lib' import { tic } from './lib/performance' -import { getRefObjectId, isObjectReference, joinReferences } from './lib/reference' +import { getRefLayer, getRefObjectId, isLayerReference, isObjectReference, joinReferences } from './lib/reference' import { objHasLayer } from './lib/timeline' export class CacheHandler { - /** A Persistant store. This object contains data that is persisted between resolves. */ + /** A Persistent store. This object contains data that is persisted between resolves. */ private cache: ResolverCache private canUseIncomingCache: boolean @@ -37,13 +37,8 @@ export class CacheHandler { const toc = tic(' cache.determineChangedObjects') // Go through all new objects, and determine whether they have changed: const allNewObjects: { [objId: string]: true } = {} - const changedReferences: { [reference: Reference]: true } = {} - const addChangedObject = (obj: ResolvedTimelineObject) => { - const references = this.getAllReferencesThisObjectAffects(obj) - for (const ref of references) { - changedReferences[ref] = true - } - } + + const changedTracker = new ChangedTracker() for (const obj of this.resolvedTimeline.objectsMap.values()) { const oldHash = this.cache.objHashes[obj.id] @@ -59,10 +54,10 @@ export class CacheHandler { oldHash !== newHash ) { this.cache.objHashes[obj.id] = newHash - addChangedObject(obj) + changedTracker.addChangedObject(obj) const oldObj = this.cache.objects[obj.id] - if (oldObj) addChangedObject(oldObj) + if (oldObj) changedTracker.addChangedObject(oldObj) } else { // No timing-affecting changes detected /* istanbul ignore if */ @@ -91,42 +86,44 @@ export class CacheHandler { if (!allNewObjects[objId]) { const obj = this.cache.objects[objId] delete this.cache.objHashes[objId] - addChangedObject(obj) + changedTracker.addChangedObject(obj) } } - // Invalidate objects, by gradually removing the invalidated ones from validObjects - // Prepare validObjects: - const validObjects: ResolvedTimelineObjects = {} + // At this point, all directly changed objects have been marked as changed. + + // Next step is to invalidate any indirectly affected objects, by gradually removing the invalidated ones from validObjects + + // Prepare the invalidator, ie populate it with the objects that are still valid: + const invalidator = new Invalidator() for (const obj of this.resolvedTimeline.objectsMap.values()) { - validObjects[obj.id] = obj + invalidator.addValidObject(obj) } - /** All references that depend on another reference (ie objects, classs or layers): */ - const affectReferenceMap: { [ref: Reference]: Reference[] } = {} for (const obj of this.resolvedTimeline.objectsMap.values()) { // Add everything that this object affects: const cachedObj = this.cache.objects[obj.id] - let affectedReferences = this.getAllReferencesThisObjectAffects(obj) + let affectedReferences = getAllReferencesThisObjectAffects(obj) if (cachedObj) { affectedReferences = joinReferences( affectedReferences, - this.getAllReferencesThisObjectAffects(cachedObj) + getAllReferencesThisObjectAffects(cachedObj) ) } for (let i = 0; i < affectedReferences.length; i++) { const ref = affectedReferences[i] const objRef: Reference = `#${obj.id}` if (ref !== objRef) { - if (!affectReferenceMap[objRef]) affectReferenceMap[objRef] = [] - affectReferenceMap[objRef].push(ref) + invalidator.addAffectedReference(objRef, ref) } } // Add everything that this object is affected by: - if (changedReferences[`#${obj.id}`]) { - // The object is directly said to be invalid, no need to add it to referencingObjects, - // since it'll be easily invalidated anyway later + if (changedTracker.isChanged(`#${obj.id}`)) { + // The object is directly said to have changed. } else { + // The object is not directly said to have changed. + // But if might have been affected by other objects that have changed. + // Note: we only have to check for the OLD object, since if the old and the new object differs, // that would mean it'll be directly invalidated anyway. if (cachedObj) { @@ -134,23 +131,28 @@ export class CacheHandler { // Note: This can be done, since _if_ the object was changed in any way since last resolve // it'll be invalidated anyway const dependOnReferences = cachedObj.resolved.directReferences + + // Build up objectLayerMap: + if (objHasLayer(cachedObj)) { + invalidator.addObjectOnLayer(`${cachedObj.layer}`, obj) + } + for (let i = 0; i < dependOnReferences.length; i++) { const ref = dependOnReferences[i] - if (!affectReferenceMap[ref]) affectReferenceMap[ref] = [] - affectReferenceMap[ref].push(`#${obj.id}`) + invalidator.addAffectedReference(ref, `#${obj.id}`) } } } } + // Invalidate all changed objects, and recursively invalidate all objects that reference those objects: - const handledReferences: { [ref: Reference]: true } = {} - for (const reference of Object.keys(changedReferences) as Reference[]) { - this.invalidateObjectsWithReference(handledReferences, reference, affectReferenceMap, validObjects) + for (const reference of changedTracker.listChanged()) { + invalidator.invalidateObjectsWithReference(reference) } - // The objects that are left in validObjects at this point are still valid. + // At this point, the objects that are left in validObjects are still valid (ie has not changed or is affected by any others). // We can reuse the old resolving for those: - for (const obj of Object.values(validObjects)) { + for (const obj of invalidator.getValidObjects()) { if (!this.cache.objects[obj.id]) /* istanbul ignore next */ throw new Error( @@ -177,67 +179,108 @@ export class CacheHandler { toc() } +} +/** Return a "hash-string" which changes whenever anything that affects timing of a timeline-object has changed. */ +export function hashTimelineObject(obj: ResolvedTimelineObject): string { + /* + Note: The following properties are ignored, as they don't affect timing or resolving: + * id + * children + * keyframes + * isGroup + * content + */ + return `${JSON.stringify(obj.enable)},${+!!obj.disabled},${obj.priority}',${obj.resolved.parentId},${+obj.resolved + .isKeyframe},${obj.classes ? obj.classes.join('.') : ''},${obj.layer},${+!!obj.seamless}` +} +function getAllReferencesThisObjectAffects(newObj: ResolvedTimelineObject): Reference[] { + const references: Reference[] = [`#${newObj.id}`] - private getAllReferencesThisObjectAffects(newObj: ResolvedTimelineObject): Reference[] { - const references: Reference[] = [`#${newObj.id}`] + if (newObj.classes) { + for (const className of newObj.classes) { + references.push(`.${className}`) + } + } + if (objHasLayer(newObj)) references.push(`$${newObj.layer}`) - if (newObj.classes) { - for (const className of newObj.classes) { - references.push(`.${className}`) - } + if (newObj.children) { + for (const child of newObj.children) { + references.push(`#${child.id}`) } - if (objHasLayer(newObj)) references.push(`$${newObj.layer}`) + } + return references +} +class ChangedTracker { + private changedReferences = new Set() - if (newObj.children) { - for (const child of newObj.children) { - references.push(`#${child.id}`) - } + public addChangedObject(obj: ResolvedTimelineObject) { + const references = getAllReferencesThisObjectAffects(obj) + for (const ref of references) { + this.changedReferences.add(ref) } - return references + if (objHasLayer(obj)) { + this.changedReferences.add(`$${obj.layer}`) + } + } + public isChanged(ref: Reference): boolean { + return this.changedReferences.has(ref) + } + public listChanged(): IterableIterator { + return this.changedReferences.keys() + } +} + +/** The Invalidator */ +class Invalidator { + private handledReferences: { [ref: Reference]: true } = {} + /** All references that depend on another reference (ie objects, class or layers): */ + private affectReferenceMap: { [ref: Reference]: Reference[] } = {} + private validObjects: ResolvedTimelineObjects = {} + /** Map of which objects can be affected by any other object, per layer */ + private objectLayerMap: { [layer: string]: string[] } = {} + + public addValidObject(obj: ResolvedTimelineObject) { + this.validObjects[obj.id] = obj + } + public getValidObjects(): ResolvedTimelineObject[] { + return Object.values(this.validObjects) + } + public addObjectOnLayer(layer: string, obj: ResolvedTimelineObject) { + if (!this.objectLayerMap[layer]) this.objectLayerMap[layer] = [] + this.objectLayerMap[layer].push(obj.id) + } + public addAffectedReference(objRef: Reference, ref: Reference) { + if (!this.affectReferenceMap[objRef]) this.affectReferenceMap[objRef] = [] + this.affectReferenceMap[objRef].push(ref) } /** Invalidate all changed objects, and recursively invalidate all objects that reference those objects */ - private invalidateObjectsWithReference( - handledReferences: { [ref: Reference]: true }, - reference: Reference, - affectReferenceMap: { [ref: Reference]: Reference[] }, - validObjects: ResolvedTimelineObjects - ) { - if (handledReferences[reference]) return // to avoid infinite loops - handledReferences[reference] = true + public invalidateObjectsWithReference(reference: Reference) { + if (this.handledReferences[reference]) return // to avoid infinite loops + this.handledReferences[reference] = true if (isObjectReference(reference)) { const objId = getRefObjectId(reference) - if (validObjects[objId]) { - delete validObjects[objId] + if (this.validObjects[objId]) { + delete this.validObjects[objId] + } + } + if (isLayerReference(reference)) { + const layer = getRefLayer(reference) + if (this.objectLayerMap[layer]) { + for (const affectedObjId of this.objectLayerMap[layer]) { + this.invalidateObjectsWithReference(`#${affectedObjId}`) + } } } // Invalidate all objects that depend on any of the references that this reference affects: - const affectedReferences = affectReferenceMap[reference] + const affectedReferences = this.affectReferenceMap[reference] if (affectedReferences) { for (let i = 0; i < affectedReferences.length; i++) { const referencingReference = affectedReferences[i] - this.invalidateObjectsWithReference( - handledReferences, - referencingReference, - affectReferenceMap, - validObjects - ) + this.invalidateObjectsWithReference(referencingReference) } } } } -/** Return a "hash-string" which changes whenever anything that affects timing of a timeline-object has changed. */ -export function hashTimelineObject(obj: ResolvedTimelineObject): string { - /* - Note: The following properties are ignored, as they don't affect timing or resolving: - * id - * children - * keyframes - * isGroup - * content - */ - return `${JSON.stringify(obj.enable)},${+!!obj.disabled},${obj.priority}',${obj.resolved.parentId},${+obj.resolved - .isKeyframe},${obj.classes ? obj.classes.join('.') : ''},${obj.layer},${+!!obj.seamless}` -} diff --git a/src/resolver/ResolvedTimelineHandler.ts b/src/resolver/ResolvedTimelineHandler.ts index 0ed2bda9..f1fdaa56 100644 --- a/src/resolver/ResolvedTimelineHandler.ts +++ b/src/resolver/ResolvedTimelineHandler.ts @@ -233,8 +233,7 @@ export class ResolvedTimelineHandler { if (obj.disabled) { resultingInstances = [] } else { - // Loop up references to the parent: - + // Look up references to the parent: let parentInstances: TimelineObjectInstance[] | null = null let hasParent = false let parentRef: ObjectReference | undefined = undefined diff --git a/src/resolver/lib/timeline.ts b/src/resolver/lib/timeline.ts index e4f7d878..b47cdc61 100644 --- a/src/resolver/lib/timeline.ts +++ b/src/resolver/lib/timeline.ts @@ -5,6 +5,6 @@ import { TimelineObject } from '../../api' * Note: Objects without a layer are called "transparent objects", * and won't be present in the resolved state. */ -export function objHasLayer(obj: TimelineObject): boolean { +export function objHasLayer(obj: TimelineObject): obj is TimelineObject & { layer: TimelineObject['layer'] } { return obj.layer !== undefined && obj.layer !== '' && obj.layer !== null }