├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── audit.yml │ ├── node.yml │ ├── publish-prerelease.yml │ └── sonar.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── MIGRATION.md ├── README.md ├── examples ├── simple.js └── simple.ts ├── jest.config.js ├── package.json ├── scratch ├── generateTimeline.ts ├── testPerformance.ts └── testWebPerformance.html ├── sonar-project.properties ├── src ├── __tests__ │ ├── basic.spec.ts │ ├── expression.spec.ts │ ├── groups.spec.ts │ ├── index.spec.ts │ ├── invalidate.spec.ts │ ├── keyframes.spec.ts │ ├── legacy.spec.ts │ ├── legacyAPI.ts │ ├── legacyEnums.ts │ ├── performance.spec.ts │ ├── performance.ts │ ├── testlib.ts │ ├── timelineGenerator.ts │ └── validate.spec.ts ├── api │ ├── expression.ts │ ├── index.ts │ ├── resolvedTimeline.ts │ ├── resolver.ts │ ├── state.ts │ ├── timeline.ts │ └── types.ts ├── index.ts └── resolver │ ├── CacheHandler.ts │ ├── ExpressionHandler.ts │ ├── InstanceHandler.ts │ ├── LayerStateHandler.ts │ ├── ReferenceHandler.ts │ ├── ResolvedTimelineHandler.ts │ ├── ResolverHandler.ts │ ├── StateHandler.ts │ ├── TimelineValidator.ts │ ├── __tests__ │ ├── InstanceHandler.spec.ts │ ├── ReferenceHandler.spec.ts │ ├── ResolvedTimelineHandler.spec.ts │ ├── StateHandler.spec.ts │ └── TimelineValidator.spec.ts │ └── lib │ ├── Error.ts │ ├── __tests__ │ ├── event.spec.ts │ ├── expression.spec.ts │ ├── instance.spec.ts │ └── lib.spec.ts │ ├── cache.ts │ ├── cap.ts │ ├── event.ts │ ├── expression.ts │ ├── instance.ts │ ├── lib.ts │ ├── operator.ts │ ├── performance.ts │ ├── reference.ts │ └── timeline.ts ├── tsconfig-examples.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | 4 | [*.{cs,js,ts,json}] 5 | indent_size = 4 6 | 7 | [*.{yml,yaml}] 8 | indent_size = 2 9 | indent_style = space -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | examples/*.js 2 | tests/*.js 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@sofie-automation/code-standard-preset/eslint/main" 3 | } 4 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Example Contributing Guidelines 2 | 3 | This is an example of GitHub's contributing guidelines file. Check out GitHub's [CONTRIBUTING.md help center article](https://help.github.com/articles/setting-guidelines-for-repository-contributors/) for more information. 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **I'm submitting a ...** 2 | [ ] bug report 3 | [ ] feature request 4 | [ ] question about the decisions made in the repository 5 | [ ] question about how to use this project 6 | 7 | * **Summary** 8 | 9 | 10 | 11 | * **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. StackOverflow, personal fork, etc.) 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 2 | 3 | 4 | 5 | * **What is the current behavior?** (You can also link to an open issue here) 6 | 7 | 8 | 9 | * **What is the new behavior (if this is a feature change)?** 10 | 11 | 12 | 13 | * **Other information**: 14 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Audit dependencies 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-dependencies: 12 | name: Validate production dependencies 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 15 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js 18.x 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.x 22 | - name: Prepare Environment 23 | run: | 24 | yarn install 25 | env: 26 | CI: true 27 | - name: Validate dependencies 28 | run: | 29 | if ! git log --format=oneline -n 1 | grep -q "\[ignore-audit\]"; then 30 | yarn validate:dependencies 31 | else 32 | echo "Skipping audit" 33 | fi 34 | env: 35 | CI: true 36 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - '[0-9]+.[0-9]+.[0-9]+*' 9 | pull_request: 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | continue-on-error: true 16 | timeout-minutes: 15 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js 18.x 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18.x 24 | - name: Prepare Environment 25 | run: | 26 | yarn install 27 | yarn build 28 | env: 29 | CI: true 30 | - name: Run typecheck and linter 31 | run: | 32 | yarn lint 33 | env: 34 | CI: true 35 | 36 | test: 37 | name: Test 38 | runs-on: ubuntu-latest 39 | timeout-minutes: 15 40 | 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | node-version: [14.x, 16.x, 18.x, 20.x] 45 | 46 | steps: 47 | - uses: actions/checkout@v3 48 | - name: Use Node.js ${{ matrix.node-version }} 49 | uses: actions/setup-node@v3 50 | with: 51 | node-version: ${{ matrix.node-version }} 52 | - name: Prepare Environment 53 | run: | 54 | yarn install 55 | env: 56 | CI: true 57 | - name: Run tests 58 | run: | 59 | yarn unit --coverage=true 60 | env: 61 | CI: true 62 | - name: Send coverage 63 | uses: codecov/codecov-action@v3 64 | if: matrix.node-version == '18.x' 65 | - name: Check docs generation 66 | if: matrix.node-version == '18.x' 67 | run: | 68 | yarn docs:test 69 | env: 70 | CI: true 71 | 72 | release: 73 | name: Release 74 | runs-on: ubuntu-latest 75 | timeout-minutes: 15 76 | 77 | # only run for tags 78 | if: contains(github.ref, 'refs/tags/') 79 | 80 | needs: 81 | - test 82 | 83 | steps: 84 | - uses: actions/checkout@v3 85 | with: 86 | fetch-depth: 0 87 | - name: Use Node.js 18.x 88 | uses: actions/setup-node@v3 89 | with: 90 | node-version: 18.x 91 | - name: Check release is desired 92 | id: do-publish 93 | run: | 94 | if [ -z "${{ secrets.NPM_TOKEN }}" ]; then 95 | echo "No Token" 96 | else 97 | 98 | PACKAGE_NAME=$(yarn info -s . name) 99 | PUBLISHED_VERSION=$(yarn info -s $PACKAGE_NAME version) 100 | THIS_VERSION=$(node -p "require('./package.json').version") 101 | # Simple bash helper to comapre version numbers 102 | verlte() { 103 | [ "$1" = "`echo -e "$1\n$2" | sort -V | head -n1`" ] 104 | } 105 | verlt() { 106 | [ "$1" = "$2" ] && return 1 || verlte $1 $2 107 | } 108 | if verlt $PUBLISHED_VERSION $THIS_VERSION 109 | then 110 | echo "Publishing latest" 111 | echo ::set-output name=tag::"latest" 112 | else 113 | echo "Publishing hotfix" 114 | echo ::set-output name=tag::"hotfix" 115 | fi 116 | 117 | fi 118 | - name: Prepare build 119 | if: ${{ steps.do-publish.outputs.tag }} 120 | run: | 121 | yarn install 122 | yarn build 123 | env: 124 | CI: true 125 | - name: Publish to NPM 126 | if: ${{ steps.do-publish.outputs.tag }} 127 | run: | 128 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc 129 | NEW_VERSION=$(node -p "require('./package.json').version") 130 | yarn publish --access=public --new-version=$NEW_VERSION --network-timeout 100000 --tag ${{ steps.do-publish.outputs.tag }} 131 | env: 132 | CI: true 133 | - name: Generate docs 134 | if: ${{ steps.do-publish.outputs.tag }} == 'latest' 135 | run: | 136 | yarn docs:html 137 | - name: Publish docs 138 | uses: peaceiris/actions-gh-pages@v3 139 | with: 140 | github_token: ${{ secrets.GITHUB_TOKEN }} 141 | publish_dir: ./docs 142 | -------------------------------------------------------------------------------- /.github/workflows/publish-prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Publish Nightly 2 | 3 | on: 4 | # Allows you to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 15 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [14.x, 16.x, 18.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Prepare Environment 25 | run: | 26 | yarn install 27 | env: 28 | CI: true 29 | - name: Run tests 30 | run: | 31 | yarn unit 32 | env: 33 | CI: true 34 | 35 | prerelease: 36 | name: Publish to NPM 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 15 39 | 40 | needs: 41 | - test 42 | 43 | steps: 44 | - uses: actions/checkout@v3 45 | with: 46 | fetch-depth: 0 47 | - name: Use Node.js 18.x 48 | uses: actions/setup-node@v3 49 | with: 50 | node-version: 18.x 51 | - name: Check release is desired 52 | id: do-publish 53 | run: | 54 | if [ -z "${{ secrets.NPM_TOKEN }}" ]; then 55 | echo "No Token" 56 | elif [[ "${{ github.ref }}" == "refs/heads/master" ]]; then 57 | echo "Publish nightly" 58 | echo ::set-output name=publish::"nightly" 59 | else 60 | echo "Publish experimental" 61 | echo ::set-output name=publish::"experimental" 62 | fi 63 | - name: Prepare Environment 64 | if: ${{ steps.do-publish.outputs.publish }} 65 | run: | 66 | yarn install 67 | env: 68 | CI: true 69 | - name: Get the Prerelease tag 70 | id: prerelease-tag 71 | uses: yuya-takeyama/docker-tag-from-github-ref-action@2b0614b1338c8f19dd9d3ea433ca9bc0cc7057ba 72 | with: 73 | remove-version-tag-prefix: false 74 | - name: Bump version and build 75 | if: ${{ steps.do-publish.outputs.publish }} 76 | run: | 77 | git config --global user.email "info@superfly.tv" 78 | git config --global user.name "superflytvab" 79 | 80 | COMMIT_TIMESTAMP=$(git log -1 --pretty=format:%ct HEAD) 81 | COMMIT_DATE=$(date -d @$COMMIT_TIMESTAMP +%Y%m%d-%H%M%S) 82 | GIT_HASH=$(git rev-parse --short HEAD) 83 | PRERELEASE_TAG=nightly-$(echo "${{ steps.prerelease-tag.outputs.tag }}" | sed -r 's/[^a-z0-9]+/-/gi') 84 | yarn release:prerelease $PRERELEASE_TAG-$COMMIT_DATE-$GIT_HASH --skip.changelog --skip.tag --skip.commit 85 | yarn build 86 | env: 87 | CI: true 88 | - name: Publish to NPM 89 | if: ${{ steps.do-publish.outputs.publish }} 90 | run: | 91 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc 92 | NEW_VERSION=$(node -p "require('./package.json').version") 93 | yarn publish --access=public --new-version=$NEW_VERSION --network-timeout 100000 --tag "${{ steps.do-publish.outputs.publish }}" 94 | env: 95 | CI: true 96 | -------------------------------------------------------------------------------- /.github/workflows/sonar.yml: -------------------------------------------------------------------------------- 1 | name: SonarCloud 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | # Allows you to run this workflow manually from the Actions tab: 9 | workflow_dispatch: 10 | jobs: 11 | sonarcloud: 12 | name: SonarCloud 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 18 | - name: Use Node.js 18.x 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.x 22 | - name: Prepare Environment 23 | run: | 24 | yarn install 25 | yarn build 26 | env: 27 | CI: true 28 | - name: Run tests 29 | run: | 30 | yarn unit --coverage=true 31 | env: 32 | CI: true 33 | - name: SonarCloud Scan 34 | uses: SonarSource/sonarcloud-github-action@master 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 37 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | test 4 | src/**.js 5 | 6 | /coverage 7 | /docs 8 | .nyc_output 9 | *.log 10 | 11 | wallaby.conf.js 12 | 13 | .DS_Store 14 | .vscode/settings.json 15 | tests/ 16 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 SuperFlyTV AB 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration instructions 2 | 3 | ## 8.x.x -> 9.x.x 4 | 5 | ### API change 6 | 7 | - `Resolver.resolveTimeline()` and `Resolver.resolveAllStates()` have been combined into one: `resolveTimeline()`. 8 | - `Resolver.getState` has been renamed to `getResolvedState`. 9 | - `validateIdString` has been renamed to `validateReferenceString`. 10 | - `resolvedTimeline.statistics` properties have changed. 11 | 12 | ```typescript 13 | // Before 14 | 15 | // Resolve the timeline 16 | const options: ResolveOptions = { 17 | time: 0, 18 | } 19 | const resolvedTimeline = Resolver.resolveTimeline(timeline, options) 20 | const resolvedStates = Resolver.resolveAllStates(resolvedTimeline) 21 | // Calculate the state at a certain time: 22 | const state = Resolver.getState(resolvedStates, 15) 23 | 24 | // After 25 | 26 | // Resolve the timeline 27 | const options: ResolveOptions = { 28 | time: 0, 29 | } 30 | const resolvedTimeline = resolveTimeline(timeline, options) 31 | // Calculate the state at a certain time: 32 | const state = getResolvedState(resolvedTimeline, 15) 33 | ``` 34 | 35 | ### Timeline logic change 36 | 37 | Before, references where evaluated on the original (non conflicted timeline-objects). 38 | After, the references are updated when a conflict affects the dependees. 39 | 40 | ```typescript 41 | const timeline = { 42 | {id: 'A', layer: '1', enable: {start: 10, end: 100}} 43 | {id: 'B', layer: '1', enable: {start: 50, end: null}} 44 | 45 | {id: 'X', layer: '1', enable: {while: '#A'}} 46 | } 47 | 48 | // Before: 49 | // A playing at [{start: 10, end: 50 }] (interrupted by B) 50 | // B playing at [{start: 50, end: null }] 51 | // X playing at [{start: 10, end: 100 }] (still references the original times of A) 52 | 53 | // After: 54 | // A playing at [{start: 10, end: 50 }] (interrupted by B) 55 | // B playing at [{start: 50, end: null }] 56 | // X playing at [{start: 10, end: 50 }] (references the updated times of A) 57 | ``` 58 | 59 | ### Modified tests: 60 | 61 | - basic.test.ts: "negative length object" 62 | Instead of resolving to an instance of negative length, it resolves to a zero-length instance 63 | - basic.test.ts: "negative length object sandwich 2" 64 | Instead of resolving to an instance of negative length, it resolves to a zero-length instance 65 | - basic.test.ts: "seamless" 66 | Zero-length enables are kept as zero-length instances (before, they where removed) 67 | - various: 68 | Instance references does now contain references on the form "#ObjId", ".className", 69 | before they could be naked strings 70 | 71 | ## 7.x.x -> 8.x.x 72 | 73 | This release dropped support for **Node 8**. 74 | 75 | ## 6.x.x -> 7.x.x 76 | 77 | ### API Change 78 | 79 | The structure of the timeline-objects has changed significantly. 80 | 81 | ```typescript 82 | // Before: 83 | const beforeTL = [ 84 | { 85 | id: 'A', 86 | trigger: { 87 | type: Timeline.Enums.TriggerType.TIME_RELATIVE, 88 | value: '#objId.start', 89 | }, 90 | duration: 60, 91 | LLayer: 1, 92 | }, 93 | { 94 | id: 'B', 95 | trigger: { 96 | type: Timeline.Enums.TriggerType.TIME_ABSOLUTE, 97 | value: 100, 98 | }, 99 | duration: 60, 100 | LLayer: 1, 101 | }, 102 | ] 103 | 104 | // After: 105 | const afterTL = [ 106 | { 107 | id: 'A', 108 | enable: { 109 | start: '#objId.start', 110 | duration: 60, 111 | }, 112 | layer: 1, 113 | }, 114 | { 115 | id: 'B', 116 | enable: { 117 | start: 100, 118 | duration: 60, 119 | }, 120 | layer: 1, 121 | }, 122 | ] 123 | ``` 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SuperFly-Timeline 2 | 3 | [![Node CI](https://github.com/SuperFlyTV/supertimeline/actions/workflows/node.yml/badge.svg)](https://github.com/SuperFlyTV/supertimeline/actions/workflows/node.yml) 4 | [![codecov](https://codecov.io/gh/SuperFlyTV/supertimeline/branch/master/graph/badge.svg)](https://codecov.io/gh/SuperFlyTV/supertimeline) 5 | [![npm](https://img.shields.io/npm/v/superfly-timeline)](https://www.npmjs.com/package/superfly-timeline) 6 | 7 | The **SuperFly-Timeline** library resolves a **Timeline**, ie calculates absolute times of the Timeline-objects, based on their relationships expressed in time-based logic expressions. 8 | 9 | ## What is a Timeline? 10 | 11 | **Timeline-objects** can be placed on a **Timeline** and their position on that timeline can be expressed as either absolute times (`{start: 100, end: 150}`) or relative to other objects (`{start: "#otherObjId.start", duration: 10}`). 12 | 13 | Timeline-objects can be placed on **layers**, where only one object will become active at a time. 14 | 15 | Timeline-objects can have **classes**, which can be referenced by other objects. 16 | 17 | Timeline-objects can have **child objects**, which are capped inside of their parents. 18 | 19 | ### Examples 20 | 21 | _Note: These examples are simplified and assumes that the time-base is in seconds, however you can choose whatever timebase you want in your implementation._ 22 | 23 | - `{start: "#A.start + 10"}`: 24 | Will start 10 seconds after `A` started. Continues indefinitely. 25 | - `{start: "(#A.start + #A.end) / 2", duration: 8}`: 26 | Will start halvway into `A`. Plays for 8 seconds. 27 | - `{while: "#A"}`: 28 | Will play whenever `A` plays. 29 | - `{while: "#A - 2"}`: 30 | Will play whenever `A` plays, and starts 2 seconds before `A`. 31 | - `{while: ".mainOutput"}`: 32 | Will play whenever anything with the class `"mainOutput"` plays. 33 | - `{while: "!.mainOutput"}`: 34 | Will play whenever there's not nothing playing with the class. 35 | 36 | SuperFly-Timeline is mainly used in the [**Sofie** TV News Studio Automation System](https://github.com/nrkno/Sofie-TV-automation/) and [SuperConductor](https://github.com/SuperFlyTV/SuperConductor). 37 | 38 | ## Installation 39 | 40 | ### NodeJS 41 | 42 | `$ npm install --save superfly-timeline` 43 | 44 | ### Web browser 45 | 46 | Can be run in the browser using _browserify_ or the like. 47 | 48 | ## Getting started 49 | 50 | [Try it in JSFiddle!](https://jsfiddle.net/nytamin/rztp517u/) 51 | 52 | ```typescript 53 | import { 54 | TimelineObject, 55 | ResolveOptions, 56 | resolveTimeline, 57 | getResolvedState, 58 | ResolvedTimelineObjectInstance, 59 | TimelineState, 60 | } from 'superfly-timeline' 61 | 62 | // The input to the timeline is an array of objects: 63 | const myTimeline: TimelineObject[] = [ 64 | { 65 | // This object represents a video, starting at time "10" and ending at time "100" 66 | id: 'video0', 67 | layer: 'videoPlayer', 68 | enable: { 69 | start: 10, 70 | end: 100, 71 | }, 72 | content: {}, 73 | classes: ['video'], 74 | }, 75 | { 76 | // This object defines a graphic template, to be overlaid on the video: 77 | id: 'graphic0', 78 | layer: 'gfxOverlay', 79 | enable: { 80 | start: '#video0.start + 5', // 5 seconds after video0 starts 81 | duration: 8, 82 | }, 83 | content: {}, 84 | }, 85 | // This object defines a graphic template, to played just before the video ends: 86 | { 87 | id: 'graphic1', 88 | layer: 'gfxOverlay', 89 | enable: { 90 | start: '#video0.end - 2', // 2 seconds before video0 ends 91 | duration: 5, 92 | }, 93 | content: {}, 94 | }, 95 | // A background video loop, to play while no video is playing: 96 | { 97 | id: 'videoBGLoop', 98 | layer: 'videoPlayer', 99 | enable: { 100 | while: '!.video', // When nothing with the class "video" is playing 101 | }, 102 | content: {}, 103 | }, 104 | ] 105 | 106 | // When we have a new timeline, the first thing to do is to "resolve" it. 107 | // This calculates all timings of the objects in the timeline. 108 | const options: ResolveOptions = { 109 | time: 0, 110 | } 111 | const resolvedTimeline = resolveTimeline(myTimeline, options) 112 | 113 | function logState(state: TimelineState) { 114 | console.log( 115 | `At the time ${state.time}, the active objects are ${Object.entries(state.layers) 116 | .map(([l, o]) => `"${o.id}" at layer "${l}"`) 117 | .join(', ')}` 118 | ) 119 | } 120 | // Check the state at time 15: 121 | logState(getResolvedState(resolvedTimeline, 15)) 122 | 123 | // Check the state at time 50: 124 | const state = getResolvedState(resolvedTimeline, 50) 125 | logState(state) 126 | 127 | // Check the next event to happen after time 50: 128 | const nextEvent = state.nextEvents[0] 129 | console.log(`After the time ${state.time}, the next event to happen will be at time ${nextEvent.time}."`) 130 | console.log(`The next event is related to the object "${nextEvent.objId}"`) 131 | 132 | // Check the state at time 99: 133 | logState(getResolvedState(resolvedTimeline, 99)) 134 | 135 | // Check the state at time 200: 136 | logState(getResolvedState(resolvedTimeline, 200)) 137 | 138 | console.log( 139 | `The object "videoBGLoop" will play at [${resolvedTimeline.objects['videoBGLoop'].resolved.instances 140 | .map((instance) => `${instance.start} to ${instance.end === null ? 'infinity' : instance.end}`) 141 | .join(', ')}]` 142 | ) 143 | 144 | // Console output: 145 | // At the time 15, the active objects are "video0" at layer "videoPlayer", "graphic0" at layer "gfxOverlay" 146 | // At the time 50, the active objects are "video0" at layer "videoPlayer" 147 | // After the time 50, the next event to happen will be at time 98." 148 | // The next event is related to the object "graphic1" 149 | // At the time 99, the active objects are "video0" at layer "videoPlayer", "graphic1" at layer "gfxOverlay" 150 | // At the time 200, the active objects are "videoBGLoop" at layer "videoPlayer" 151 | // The object "videoBGLoop" will play at [0 to 10, 100 to infinity] 152 | ``` 153 | 154 | # API 155 | 156 | The logic is set by setting properties in the `.enable` property. 157 | 158 | | Property | Description | 159 | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | 160 | | `.start` | The start time of the object. (cannot be combined with `.while`) | 161 | | `.end` | The end time of the object (cannot be combined with `.while` or `.duration`). | 162 | | `.while` | Enables the object WHILE expression is true (ie sets both the start and end). (cannot be combined with `.start`, `.end` or `.duration` ) | 163 | | `.duration` | The duration of an object | 164 | | `.repeating` | Makes the object repeat with given interval | 165 | 166 | Note: If neither `.end`, `.duration`, or `.while` is set, the object will continue indefinitely. 167 | 168 | **Examples** 169 | 170 | ```javascript 171 | { 172 | enable: { 173 | start: '#abc.end + 5', // Start 5 seconds after #abc ends 174 | duration: '#abc.duration' // Have the same duration as #abc 175 | } 176 | } 177 | ``` 178 | 179 | [Try it in JSFiddle!](https://jsfiddle.net/nytamin/kq6wfcov/) 180 | 181 | ```javascript 182 | { 183 | enable: { 184 | while: '#abc', // Enable while #abc is enabled 185 | } 186 | } 187 | ``` 188 | 189 | [Try it in JSFiddle!](https://jsfiddle.net/nytamin/qjw7hL5x/) 190 | 191 | ## State & Layers 192 | 193 | All objects will be on a **layer** in the resolved **state**. There are a few rules: 194 | 195 | - Only **one** object can exist on a layer at the same time. 196 | - If two (or more) objects conflict, ie fight for the place on a layer: 197 | - The one with highest `.priority` will win. 198 | - If tied, the one with _latest start time_ will win. 199 | 200 | **Example** 201 | 202 | ```javascript 203 | { 204 | id: 'A', 205 | layer: 'L1', 206 | enable: { start: 10, end: 100 }, 207 | content: {}, 208 | }, 209 | { 210 | id: 'B', 211 | layer: 'L1', 212 | enable: { start: 50, duration: 10 }, 213 | content: {}, 214 | } 215 | // This will cause the timeline to be: 216 | // A on layer L1 for 10 - 50 217 | // B on layer L1 for 50 - 60 218 | // A on layer L1 for 60 - 100 219 | 220 | ``` 221 | 222 | [Try it in JSFiddle!](https://jsfiddle.net/nytamin/excb84ky/) 223 | 224 | ## References 225 | 226 | ### Reference types 227 | 228 | | Reference | Description | 229 | | ------------ | ------------------------------------------------------------------- | 230 | | `#objId` | Reference to the object that has the specified **.id** | 231 | | `##parent` | Reference to the group that contains this object | 232 | | `.className` | Reference to any object that has the class-name in its **.classes** | 233 | | `$layerName` | Reference to any object that is on the specified layer (**.layer**) | 234 | 235 | ### Reference modifiers 236 | 237 | The references listed above can be modified: 238 | | Example | Description | 239 | |--|--| 240 | | `#objId.start` | Refer to the start of the object | 241 | | `#objId.end` | Refer to the end of the object | 242 | | `#objId.duration` | Refer to the duration of the object | 243 | 244 | ### Reference combinations 245 | 246 | The references can be combined using arithmetic (`+ - \* / % ( )`) and boolean operators (`& | ! `) 247 | 248 | **Examples** 249 | 250 | ```javascript 251 | { 252 | // Start halfway in: 253 | enable: { 254 | start: '#abc.start + #abc.duration / 2' 255 | } 256 | } 257 | ``` 258 | 259 | [Try it in JSFiddle!](https://jsfiddle.net/nytamin/2jmsgu6h/) 260 | 261 | ```javascript 262 | { // Enable while #sun and #moon, but not #jupiter: 263 | enable: { while: '#sun & #moon & !#jupiter', } 264 | } 265 | ``` 266 | 267 | [Try it in JSFiddle!](https://jsfiddle.net/nytamin/nuobkgdw/) 268 | 269 | --- 270 | 271 | ## Keyframes 272 | 273 | It is also possible to add keyframes to an object. A keyframe follows the same logic as other timeline-objects and can reference (and be referenced) as such. 274 | 275 | When the keyframe is active, its content is deeply applied onto the parents `.content` in the `ResolvedState`. 276 | 277 | **Example** 278 | 279 | ```javascript 280 | const tl = { 281 | id: 'myObj', 282 | layer: 'L1', 283 | enable: { 284 | start: 10, 285 | end: 100, 286 | }, 287 | content: { 288 | opacity: 100, 289 | }, 290 | keyframes: [ 291 | { 292 | id: 'kf0', 293 | enable: { 294 | start: 5, // relative to parent, so will start at 15 295 | duration: 10, 296 | }, 297 | content: { 298 | opacity: 0, 299 | }, 300 | }, 301 | ], 302 | } 303 | // This will cause the object to 304 | // * Be active at 10 - 100 305 | // * Have opacity = 100 at 10 - 15 306 | // * Have opacity = 0 at 15 - 25 307 | // * Have opacity = 100 at 25 - 100 308 | ``` 309 | 310 | ## Groups 311 | 312 | It is also possible to add groups that contain other objects as children. The children will always be capped within their parent. 313 | 314 | Groups can work in 2 ways: 315 | 316 | - A _"Transparent group"_ **does not** have a `.layer` assigned to it (or it's set to ''). A transparent group does not "collide" with other objects, nor be visible in the calculated state. But its children objects will always be put on the timeline. 317 | - A _"Normal group"_ **does** have a `.layer` assigned to it. This means that the group works the same way as normal objects, and can collide with them. The children of the group will only be enabled while the parent is enabled. 318 | 319 | **Example** 320 | 321 | ```javascript 322 | { 323 | id: 'myGroup', 324 | layer: '', 325 | enable: { 326 | start: 10, 327 | duration: 10, 328 | repeat: 20 // Repeat every 20 seconds, so will start at 10, 30, 50 etc... 329 | }, 330 | content: {}, 331 | isGroup: true, 332 | children: [{ 333 | id: 'child0', 334 | layer: 'L1', 335 | enable: { 336 | start: 2, // Will repeat with parent, so will start at 12, 32, 52 etc... 337 | duration: null // Duration not set, but will be capped within parent, so will end at 20, 40, 60 etc... 338 | }, 339 | content: {}, 340 | }] 341 | 342 | } 343 | ``` 344 | 345 | [Try it in JSFiddle!](https://jsfiddle.net/nytamin/ydznup0k/) 346 | 347 | --- 348 | 349 | Please note that in the examples above the times have been defined in seconds. 350 | This is for readability only, you may use whatever time-base you like (like milliseconds) in your implementation. 351 | 352 | ## Changelog and Breaking changes 353 | 354 | See [CHANGELOG.md](./CHANGELOG.md) 355 | 356 | For notes on breaking changes, see [MIGRATION.md](./MIGRATION.md) 357 | 358 | # For developers 359 | 360 | ## Developing 361 | 362 | ```bash 363 | corepack enable 364 | yarn 365 | yarn build 366 | yarn test 367 | ``` 368 | 369 | ## Do a release 370 | 371 | - `yarn release:release` 372 | - Push the commit and tag. 373 | - GitHub Actions will publish the package to npm. 374 | -------------------------------------------------------------------------------- /examples/simple.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const __1 = require(".."); // 'superfly-timeline' 4 | // The input to the timeline is an array of objects: 5 | const myTimeline = [ 6 | { 7 | // This object represents a video, starting at time "10" and ending at time "100" 8 | id: 'video0', 9 | layer: 'videoPlayer', 10 | enable: { 11 | start: 10, 12 | end: 100, 13 | }, 14 | content: {}, 15 | classes: ['video'], 16 | }, 17 | { 18 | // This object defines a graphic template, to be overlaid on the video: 19 | id: 'graphic0', 20 | layer: 'gfxOverlay', 21 | enable: { 22 | start: '#video0.start + 5', 23 | duration: 8, 24 | }, 25 | content: {}, 26 | }, 27 | // This object defines a graphic template, to played just before the video ends: 28 | { 29 | id: 'graphic1', 30 | layer: 'gfxOverlay', 31 | enable: { 32 | start: '#video0.end - 2', 33 | duration: 5, 34 | }, 35 | content: {}, 36 | }, 37 | // A background video loop, to play while no video is playing: 38 | { 39 | id: 'videoBGLoop', 40 | layer: 'videoPlayer', 41 | enable: { 42 | while: '!.video', // When nothing with the class "video" is playing 43 | }, 44 | content: {}, 45 | }, 46 | ]; 47 | // When we have a new timeline, the first thing to do is to "resolve" it. 48 | // This calculates all timings of the objects in the timeline. 49 | const options = { 50 | time: 0, 51 | }; 52 | const resolvedTimeline = (0, __1.resolveTimeline)(myTimeline, options); 53 | function logState(state) { 54 | console.log(`At the time ${state.time}, the active objects are ${Object.entries(state.layers) 55 | .map(([l, o]) => `"${o.id}" at layer "${l}"`) 56 | .join(', ')}`); 57 | } 58 | // Note: A "State" is a moment in time, containing all objects that are active at that time. 59 | { 60 | // Check the state at time 15: 61 | const state = (0, __1.getResolvedState)(resolvedTimeline, 15); 62 | logState(state); 63 | } 64 | { 65 | // Check the state at time 50: 66 | const state = (0, __1.getResolvedState)(resolvedTimeline, 50); 67 | logState(state); 68 | // Check the next event to happen after time 50: 69 | const nextEvent = state.nextEvents[0]; 70 | console.log(`After the time ${state.time}, the next event to happen will be at time ${nextEvent.time}."`); 71 | console.log(`The next event is related to the object "${nextEvent.objId}"`); 72 | } 73 | { 74 | // Check the state at time 99: 75 | const state = (0, __1.getResolvedState)(resolvedTimeline, 99); 76 | logState(state); 77 | } 78 | { 79 | // Check the state at time 200: 80 | const state = (0, __1.getResolvedState)(resolvedTimeline, 200); 81 | logState(state); 82 | } 83 | console.log(`The object "videoBGLoop" will play at [${resolvedTimeline.objects['videoBGLoop'].resolved.instances 84 | .map((instance) => `${instance.start} to ${instance.end === null ? 'infinity' : instance.end}`) 85 | .join(', ')}]`); 86 | // Console output: 87 | // At the time 15, the active objects are "video0" at layer "videoPlayer", "graphic0" at layer "gfxOverlay" 88 | // At the time 50, the active objects are "video0" at layer "videoPlayer" 89 | // After the time 50, the next event to happen will be at time 98." 90 | // The next event is related to the object "graphic1" 91 | // At the time 99, the active objects are "video0" at layer "videoPlayer", "graphic1" at layer "gfxOverlay" 92 | // At the time 200, the active objects are "videoBGLoop" at layer "videoPlayer" 93 | // The object "videoBGLoop" will play at [0 to 10, 100 to infinity] 94 | -------------------------------------------------------------------------------- /examples/simple.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TimelineObject, 3 | ResolveOptions, 4 | resolveTimeline, 5 | getResolvedState, 6 | ResolvedTimelineObjectInstance, 7 | TimelineState, 8 | } from '..' // 'superfly-timeline' 9 | 10 | // The input to the timeline is an array of objects: 11 | const myTimeline: TimelineObject[] = [ 12 | { 13 | // This object represents a video, starting at time "10" and ending at time "100" 14 | id: 'video0', 15 | layer: 'videoPlayer', 16 | enable: { 17 | start: 10, 18 | end: 100, 19 | }, 20 | content: {}, 21 | classes: ['video'], 22 | }, 23 | { 24 | // This object defines a graphic template, to be overlaid on the video: 25 | id: 'graphic0', 26 | layer: 'gfxOverlay', 27 | enable: { 28 | start: '#video0.start + 5', // 5 seconds after video0 starts 29 | duration: 8, 30 | }, 31 | content: {}, 32 | }, 33 | // This object defines a graphic template, to played just before the video ends: 34 | { 35 | id: 'graphic1', 36 | layer: 'gfxOverlay', 37 | enable: { 38 | start: '#video0.end - 2', // 2 seconds before video0 ends 39 | duration: 5, 40 | }, 41 | content: {}, 42 | }, 43 | // A background video loop, to play while no video is playing: 44 | { 45 | id: 'videoBGLoop', 46 | layer: 'videoPlayer', 47 | enable: { 48 | while: '!.video', // When nothing with the class "video" is playing 49 | }, 50 | content: {}, 51 | }, 52 | ] 53 | 54 | // When we have a new timeline, the first thing to do is to "resolve" it. 55 | // This calculates all timings of the objects in the timeline. 56 | const options: ResolveOptions = { 57 | time: 0, 58 | } 59 | const resolvedTimeline = resolveTimeline(myTimeline, options) 60 | 61 | function logState(state: TimelineState) { 62 | console.log( 63 | `At the time ${state.time}, the active objects are ${Object.entries( 64 | state.layers 65 | ) 66 | .map(([l, o]) => `"${o.id}" at layer "${l}"`) 67 | .join(', ')}` 68 | ) 69 | } 70 | // Note: A "State" is a moment in time, containing all objects that are active at that time. 71 | { 72 | // Check the state at time 15: 73 | const state = getResolvedState(resolvedTimeline, 15) 74 | logState(state) 75 | } 76 | { 77 | // Check the state at time 50: 78 | const state = getResolvedState(resolvedTimeline, 50) 79 | logState(state) 80 | // Check the next event to happen after time 50: 81 | const nextEvent = state.nextEvents[0] 82 | console.log(`After the time ${state.time}, the next event to happen will be at time ${nextEvent.time}.`) 83 | console.log(`The next event is related to the object "${nextEvent.objId}"`) 84 | } 85 | { 86 | // Check the state at time 99: 87 | const state = getResolvedState(resolvedTimeline, 99) 88 | logState(state) 89 | } 90 | { 91 | // Check the state at time 200: 92 | const state = getResolvedState(resolvedTimeline, 200) 93 | logState(state) 94 | } 95 | 96 | console.log( 97 | `The object "videoBGLoop" will play at [${resolvedTimeline.objects['videoBGLoop'].resolved.instances 98 | .map((instance) => `${instance.start} to ${instance.end === null ? 'infinity' : instance.end}`) 99 | .join(', ')}]` 100 | ) 101 | 102 | // Console output: 103 | // At the time 15, the active objects are "video0" at layer "videoPlayer", "graphic0" at layer "gfxOverlay" 104 | // At the time 50, the active objects are "video0" at layer "videoPlayer" 105 | // After the time 50, the next event to happen will be at time 98. 106 | // The next event is related to the object "graphic1" 107 | // At the time 99, the active objects are "video0" at layer "videoPlayer", "graphic1" at layer "gfxOverlay" 108 | // At the time 200, the active objects are "videoBGLoop" at layer "videoPlayer" 109 | // The object "videoBGLoop" will play at [0 to 10, 100 to infinity] 110 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['ts', 'js'], 3 | transform: { 4 | '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json', diagnostics: true }], 5 | }, 6 | testMatch: ['**/src/**/__tests__/**/*.spec.(ts|js)'], 7 | testEnvironment: 'node', 8 | coverageThreshold: { 9 | global: { 10 | branches: 0, 11 | functions: 0, 12 | lines: 0, 13 | statements: 0, 14 | }, 15 | }, 16 | coverageDirectory: './coverage/', 17 | collectCoverage: false, 18 | collectCoverageFrom: [ 19 | 'src/**/*.ts', 20 | '!**/__tests__/**', 21 | // Ignore, it is only used for performance testing: 22 | '!src/resolver/lib/performance.ts', 23 | ], 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superfly-timeline", 3 | "version": "9.1.2", 4 | "description": "Resolver for defining objects with temporal boolean logic relationships on a timeline", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Johan Nyman", 8 | "email": "johan@superfly.tv" 9 | }, 10 | "contributors": [ 11 | { 12 | "name": "Julian Waller", 13 | "email": "julian@superfly.tv" 14 | }, 15 | { 16 | "name": "Jesper Stærkær", 17 | "email": "jesper@superfly.tv" 18 | }, 19 | { 20 | "name": "Krzysztof Zegzuła", 21 | "email": "krzysztof@superfly.tv" 22 | }, 23 | { 24 | "name": "Jan Starzak", 25 | "email": "jan@superfly.tv" 26 | }, 27 | { 28 | "name": "Jonas Hummelstrand", 29 | "email": "jan@superfly.tv" 30 | }, 31 | { 32 | "name": "Ola Christian Gundelsby", 33 | "email": "ola.christian.gundelsby@nrk.no" 34 | }, 35 | { 36 | "name": "Stephan Nordnes Eriksen", 37 | "email": "Stephanruler@gmail.com" 38 | } 39 | ], 40 | "homepage": "http://superfly.tv", 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/SuperFlyTV/supertimeline.git" 44 | }, 45 | "main": "dist/index.js", 46 | "typings": "dist/index.d.ts", 47 | "bugs": { 48 | "url": "https://github.com/SuperFlyTV/supertimeline/issues" 49 | }, 50 | "scripts": { 51 | "prepare": "husky install", 52 | "build": "rimraf dist && yarn build:main", 53 | "build:main": "tsc -p tsconfig.build.json", 54 | "build-examples": "yarn build && yarn build:examples", 55 | "build:examples": "tsc -p tsconfig-examples.json", 56 | "lint:raw": "eslint --ext .ts --ext .js --ext .tsx --ext .jsx --ignore-pattern dist --ignore-pattern docs", 57 | "lint": "yarn lint:raw .", 58 | "lint-fix": "yarn lint --fix", 59 | "unit": "jest --forceExit --detectOpenHandles", 60 | "test": "yarn lint && yarn unit", 61 | "watch": "jest --watch", 62 | "cov": "yarn unit --coverage=true && yarn cov-open", 63 | "cov-open": "open-cli coverage/lcov-report/index.html", 64 | "docs": "yarn docs:html && open-cli docs/index.html", 65 | "docs:test": "yarn docs:html", 66 | "docs:html": "typedoc src/index.ts --excludePrivate --theme default --out docs --tsconfig tsconfig.build.json", 67 | "docs:json": "typedoc --json docs/typedoc.json src/index.ts --tsconfig tsconfig.build.json", 68 | "release:release": "standard-version", 69 | "release:prerelease": "standard-version --prerelease", 70 | "reset": "git clean -dfx && git reset --hard && yarn", 71 | "validate:dependencies": "yarn audit --groups dependencies && yarn license-validate", 72 | "validate:dev-dependencies": "yarn audit --groups devDependencies", 73 | "license-validate": "yarn sofie-licensecheck --allowPackages=caniuse-lite@1.0.30001429" 74 | }, 75 | "engines": { 76 | "node": ">=14" 77 | }, 78 | "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", 79 | "lint-staged": { 80 | "*.{css,json,md,scss}": [ 81 | "prettier --write" 82 | ], 83 | "*.{ts,tsx,js,jsx}": [ 84 | "yarn lint:raw --fix" 85 | ] 86 | }, 87 | "files": [ 88 | "/dist", 89 | "/CHANGELOG.md", 90 | "/README.md", 91 | "/LICENSE" 92 | ], 93 | "devDependencies": { 94 | "@sofie-automation/code-standard-preset": "^2.5.1", 95 | "@types/jest": "^29.5.2", 96 | "@types/node": "^20", 97 | "jest": "^29.6.3", 98 | "open-cli": "^7.2.0", 99 | "rimraf": "^5.0.1", 100 | "standard-version": "^9.5.0", 101 | "ts-jest": "^29.1.1", 102 | "ts-node": "^10.9.1", 103 | "typedoc": "^0.24.8", 104 | "typescript": "~4.9" 105 | }, 106 | "keywords": [ 107 | "broadcast", 108 | "typescript", 109 | "javascript", 110 | "open", 111 | "source" 112 | ], 113 | "dependencies": { 114 | "tslib": "^2.6.0" 115 | }, 116 | "standard-version": { 117 | "message": "chore(release): %s", 118 | "tagPrefix": "" 119 | }, 120 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 121 | } 122 | -------------------------------------------------------------------------------- /scratch/generateTimeline.ts: -------------------------------------------------------------------------------- 1 | import { generateTimeline } from '../src/__tests__/timelineGenerator' 2 | import * as fs from 'fs' 3 | 4 | const seed = 49 5 | const count = 100 6 | const depth = 3 7 | 8 | fs.writeFileSync('./generatedTimeline.json', JSON.stringify(generateTimeline(seed, count, depth))) 9 | -------------------------------------------------------------------------------- /scratch/testPerformance.ts: -------------------------------------------------------------------------------- 1 | import { doPerformanceTest, round } from '../src/__tests__/performance' 2 | import * as readline from 'readline' 3 | 4 | /******************************************** 5 | * This file is intened to be used to test the performance while having the debugger attached. 6 | * To run: 7 | * node --inspect -r ts-node/register scratch\testPerformance.ts 8 | ********************************************/ 9 | 10 | const TEST_COUNT = 1000 11 | 12 | async function askQuestion(query: string) { 13 | const rl = readline.createInterface({ 14 | input: process.stdin, 15 | output: process.stdout, 16 | }) 17 | 18 | return new Promise((resolve) => 19 | rl.question(query, (ans: any) => { 20 | rl.close() 21 | resolve(ans) 22 | }) 23 | ) 24 | } 25 | 26 | ;(async () => { 27 | await askQuestion('Press enter to start test') 28 | 29 | const { sortedTimes, executionTimeAvg } = doPerformanceTest(TEST_COUNT, false) 30 | 31 | console.log( 32 | `Average time of execution: ${round(executionTimeAvg)} ms\n` + 33 | 'Worst 5:\n' + 34 | sortedTimes 35 | .slice(-5) 36 | .map((t) => `${t.key}: ${round(t.time)} ms`) 37 | .join('\n') 38 | ) 39 | })().catch((e) => { 40 | console.error(e) 41 | // eslint-disable-next-line no-process-exit 42 | process.exit(1) 43 | }) 44 | -------------------------------------------------------------------------------- /scratch/testWebPerformance.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | Test web performance 9 | 10 | 11 | 12 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=SuperFlyTV_supertimeline 2 | sonar.organization=superflytv 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=supertimeline 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | #sonar.sources=. 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | #sonar.sourceEncoding=UTF-8 13 | -------------------------------------------------------------------------------- /src/__tests__/expression.spec.ts: -------------------------------------------------------------------------------- 1 | import { interpretExpression, wrapInnerExpressions, simplifyExpression, validateExpression, onCloseCleanup } from '..' 2 | 3 | describe('Expression', () => { 4 | afterAll(() => { 5 | onCloseCleanup() 6 | }) 7 | test('interpretExpression from string', () => { 8 | expect(interpretExpression('42.5')).toEqual(42.5) 9 | expect(interpretExpression('+42.5')).toEqual(42.5) 10 | expect(interpretExpression('-42.5')).toEqual(-42.5) 11 | 12 | expect(() => interpretExpression('45 +')).toThrow() 13 | 14 | expect(interpretExpression('1+2')).toMatchObject({ 15 | l: '1', 16 | o: '+', 17 | r: '2', 18 | }) 19 | expect(interpretExpression(' 1 * 2 ')).toMatchObject({ 20 | l: '1', 21 | o: '*', 22 | r: '2', 23 | }) 24 | 25 | expect(interpretExpression('1 + 2')).toMatchObject({ 26 | l: '1', 27 | o: '+', 28 | r: '2', 29 | }) 30 | expect(interpretExpression('1 - 2')).toMatchObject({ 31 | l: '1', 32 | o: '-', 33 | r: '2', 34 | }) 35 | expect(interpretExpression('1 * 2')).toMatchObject({ 36 | l: '1', 37 | o: '*', 38 | r: '2', 39 | }) 40 | expect(interpretExpression('1 / 2')).toMatchObject({ 41 | l: '1', 42 | o: '/', 43 | r: '2', 44 | }) 45 | expect(interpretExpression('1 % 2')).toMatchObject({ 46 | l: '1', 47 | o: '%', 48 | r: '2', 49 | }) 50 | expect(interpretExpression('1 + 2 * 3')).toMatchObject({ 51 | l: '1', 52 | o: '+', 53 | r: { 54 | l: '2', 55 | o: '*', 56 | r: '3', 57 | }, 58 | }) 59 | expect(interpretExpression('1 * 2 + 3')).toMatchObject({ 60 | l: { 61 | l: '1', 62 | o: '*', 63 | r: '2', 64 | }, 65 | o: '+', 66 | r: '3', 67 | }) 68 | expect(interpretExpression('1 * (2 + 3)')).toMatchObject({ 69 | l: '1', 70 | o: '*', 71 | r: { 72 | l: '2', 73 | o: '+', 74 | r: '3', 75 | }, 76 | }) 77 | expect(interpretExpression('#first & #second')).toMatchObject({ 78 | l: '#first', 79 | o: '&', 80 | r: '#second', 81 | }) 82 | 83 | expect(interpretExpression('!thisOne')).toMatchObject({ 84 | l: '', 85 | o: '!', 86 | r: 'thisOne', 87 | }) 88 | 89 | expect(interpretExpression('!thisOne & !(that | !those)')).toMatchObject({ 90 | l: { 91 | l: '', 92 | o: '!', 93 | r: 'thisOne', 94 | }, 95 | o: '&', 96 | r: { 97 | l: '', 98 | o: '!', 99 | r: { 100 | l: 'that', 101 | o: '|', 102 | r: { 103 | l: '', 104 | o: '!', 105 | r: 'those', 106 | }, 107 | }, 108 | }, 109 | }) 110 | 111 | expect(interpretExpression('(!.classA | !$layer.classB) & #obj')).toMatchObject({ 112 | l: { 113 | l: { 114 | l: '', 115 | o: '!', 116 | r: '.classA', 117 | }, 118 | o: '|', 119 | r: { 120 | l: '', 121 | o: '!', 122 | r: '$layer.classB', 123 | }, 124 | }, 125 | o: '&', 126 | r: '#obj', 127 | }) 128 | 129 | expect(interpretExpression('#obj.start')).toEqual('#obj.start') 130 | 131 | expect(interpretExpression(19.2)).toEqual(19.2) 132 | expect(interpretExpression(null)).toEqual(null) 133 | }) 134 | test('wrapInnerExpressions', () => { 135 | expect(wrapInnerExpressions(['a', '(', 'b', 'c', ')'])).toEqual({ rest: [], inner: ['a', ['b', 'c']] }) 136 | expect(wrapInnerExpressions(['a', '&', '!', 'b'])).toEqual({ rest: [], inner: ['a', '&', ['', '!', 'b']] }) 137 | }) 138 | test('simplifyExpression', () => { 139 | expect(simplifyExpression('1+2+3')).toEqual(6) 140 | 141 | expect(simplifyExpression('1+2*2+(4-2)')).toEqual(7) 142 | 143 | expect(simplifyExpression('10 / 2 + 1')).toEqual(6) 144 | 145 | expect(simplifyExpression('40+2+asdf')).toEqual({ 146 | l: 42, 147 | o: '+', 148 | r: 'asdf', 149 | }) 150 | 151 | expect(simplifyExpression('42 % 10')).toEqual(2) 152 | expect(simplifyExpression('42 % asdf')).toEqual({ 153 | l: 42, 154 | o: '%', 155 | r: 'asdf', 156 | }) 157 | 158 | // &: numbers can't really be combined: 159 | expect(simplifyExpression('5 & 1')).toEqual({ 160 | l: 5, 161 | o: '&', 162 | r: 1, 163 | }) 164 | }) 165 | test('validateExpression', () => { 166 | expect(validateExpression(['+', '-'], '1+1')).toEqual(true) 167 | expect(validateExpression(['+', '-'], { l: 1, o: '+', r: 1 })).toEqual(true) 168 | 169 | // @ts-ignore 170 | expect(() => validateExpression(['+', '-'], { l: 1, o: '+' })).toThrow(/missing/) 171 | // @ts-ignore 172 | expect(() => validateExpression(['+', '-'], { o: '+', r: 1 })).toThrow(/missing/) 173 | // @ts-ignore 174 | expect(() => validateExpression(['+', '-'], { l: 1, o: 12, r: 1 })).toThrow(/not a string/) 175 | // @ts-ignore 176 | expect(() => validateExpression(['+', '-'], { l: 1, r: 1 })).toThrow(/missing/) 177 | 178 | expect(() => validateExpression(['+', '-'], { l: 1, o: '*', r: 1 })).toThrow(/not valid/) 179 | // @ts-ignore 180 | expect(() => validateExpression(['+', '-'], { l: 1, o: '+', r: [] })).toThrow(/invalid type/) 181 | 182 | expect(() => validateExpression(['+', '-'], { l: 1, o: '+', r: { l: 1, o: '+', r: 1 } })).not.toThrow() 183 | expect(() => validateExpression(['+', '-'], { l: 1, o: '+', r: { l: 1, o: '*', r: 1 } })).toThrow(/not valid/) 184 | expect(() => validateExpression(['+', '-'], { r: 1, o: '+', l: { l: 1, o: '*', r: 1 } })).toThrow(/not valid/) 185 | }) 186 | test('unknown operator', () => { 187 | let errString = '' 188 | try { 189 | interpretExpression('1 _ 2') 190 | } catch (e) { 191 | errString = `${e}` 192 | } 193 | expect(errString).toMatch(/operator not found/) 194 | }) 195 | afterAll(() => { 196 | onCloseCleanup() 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /src/__tests__/invalidate.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-standalone-expect */ 2 | import { TimelineObject } from '../' 3 | import { describeVariants, resolveTimeline, getResolvedState } from './testlib' 4 | 5 | function clone(o: T): T { 6 | return JSON.parse(JSON.stringify(o)) 7 | } 8 | describeVariants( 9 | 'Resolver, using Cache', 10 | (test, fixTimeline, _getCache) => { 11 | beforeEach(() => { 12 | // resetId() 13 | }) 14 | test('Changing timeline', () => { 15 | const video = { 16 | id: 'video', 17 | layer: '0', 18 | enable: { 19 | start: 0, 20 | end: 100, 21 | }, 22 | content: {}, 23 | } 24 | const graphic0 = { 25 | id: 'graphic0', 26 | layer: '1', 27 | enable: { 28 | start: '#video.start + 10', // 10 29 | duration: 10, 30 | }, 31 | content: {}, 32 | } 33 | const graphic1 = { 34 | id: 'graphic1', 35 | layer: '1', 36 | enable: { 37 | start: '#graphic0.end + 10', // 30 38 | duration: 15, 39 | }, 40 | content: {}, 41 | } 42 | 43 | const timeline = fixTimeline([video, graphic0, graphic1]) 44 | const cache = {} 45 | const resolved = resolveTimeline(timeline, { time: 0, cache }) 46 | 47 | expect(resolved.statistics.resolvingObjectCount).toEqual(3) 48 | expect(resolved.statistics.resolvedObjectCount).toEqual(3) 49 | expect(resolved.objects['video'].resolved).toMatchObject({ instances: [{ start: 0, end: 100 }] }) 50 | expect(resolved.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) 51 | expect(resolved.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 30, end: 45 }] }) 52 | 53 | // make a small change in timeline: 54 | // @ts-ignore 55 | graphic1.enable.start = '#graphic0.end + 15' // 35 56 | 57 | const resolved2 = resolveTimeline(timeline, { time: 0, cache }) 58 | expect(resolved2.statistics.resolvingObjectCount).toEqual(2) 59 | expect(resolved2.objects['video'].resolved).toMatchObject({ instances: [{ start: 0, end: 100 }] }) 60 | expect(resolved2.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) 61 | expect(resolved2.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 35, end: 50 }] }) 62 | 63 | // make another change in timeline: 64 | // @ts-ignore 65 | video.enable.start = 10 66 | 67 | const resolved3 = resolveTimeline(timeline, { time: 0, cache }) 68 | expect(resolved3.statistics.resolvingObjectCount).toEqual(3) 69 | expect(resolved3.objects['video'].resolved).toMatchObject({ instances: [{ start: 10, end: 100 }] }) 70 | expect(resolved3.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 20, end: 30 }] }) 71 | expect(resolved3.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 45, end: 60 }] }) 72 | 73 | // run the exact thing again, with no timeline changes: 74 | const resolved4 = resolveTimeline(timeline, { time: 0, cache }) 75 | expect(resolved4.statistics.resolvingObjectCount).toEqual(0) 76 | expect(resolved4.objects['video'].resolved).toMatchObject({ instances: [{ start: 10, end: 100 }] }) 77 | expect(resolved4.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 20, end: 30 }] }) 78 | expect(resolved4.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 45, end: 60 }] }) 79 | }) 80 | test('Reference class', () => { 81 | const video0 = { 82 | id: 'video0', 83 | layer: '0', 84 | enable: { 85 | start: 10, 86 | duration: 10, 87 | }, 88 | content: {}, 89 | classes: ['someVideo0'], 90 | } 91 | const graphic0 = { 92 | id: 'graphic0', 93 | layer: '1', 94 | enable: { 95 | start: '.someVideo0.end', 96 | duration: 10, 97 | }, 98 | content: {}, 99 | } 100 | const graphic1 = { 101 | id: 'graphic1', 102 | layer: '1', 103 | enable: { 104 | start: '.someVideo1.end', 105 | duration: 10, 106 | }, 107 | content: {}, 108 | } 109 | const timeline = fixTimeline([video0, graphic0, graphic1]) 110 | const cache = {} 111 | const resolved = resolveTimeline(timeline, { time: 0, cache }) 112 | 113 | expect(resolved.statistics.resolvingObjectCount).toEqual(3) 114 | expect(resolved.objects['video0'].resolved.instances).toMatchObject([{ start: 10, end: 20 }]) 115 | expect(resolved.objects['graphic0'].resolved.instances).toMatchObject([{ start: 20, end: 30 }]) 116 | expect(resolved.objects['graphic1'].resolved.instances).toHaveLength(0) 117 | 118 | // change the timeline 119 | // @ts-ignore 120 | video0.enable.start = 20 121 | 122 | const resolved2 = resolveTimeline(timeline, { time: 0, cache }) 123 | 124 | expect(resolved2.statistics.resolvingObjectCount).toEqual(3) 125 | expect(resolved2.objects['video0'].resolved.instances).toMatchObject([{ start: 20, end: 30 }]) 126 | expect(resolved2.objects['graphic0'].resolved.instances).toMatchObject([{ start: 30, end: 40 }]) 127 | expect(resolved2.objects['graphic1'].resolved.instances).toHaveLength(0) 128 | 129 | // change the class 130 | video0.classes = ['someVideo1'] 131 | 132 | const resolved3 = resolveTimeline(timeline, { time: 0, cache }) 133 | 134 | expect(resolved3.statistics.resolvingObjectCount).toEqual(3) 135 | expect(resolved3.objects['video0'].resolved.instances).toMatchObject([{ start: 20, end: 30 }]) 136 | expect(resolved3.objects['graphic0'].resolved.instances).toHaveLength(0) 137 | expect(resolved3.objects['graphic1'].resolved.instances).toMatchObject([{ start: 30, end: 40 }]) 138 | }) 139 | test('Reference layer', () => { 140 | const video0 = { 141 | id: 'video0', 142 | layer: '0', 143 | enable: { 144 | start: 10, 145 | duration: 10, 146 | }, 147 | content: {}, 148 | classes: ['someVideo0'], 149 | } 150 | const graphic0 = { 151 | id: 'graphic0', 152 | layer: '9', 153 | enable: { 154 | start: '$0.end', 155 | duration: 10, 156 | }, 157 | content: {}, 158 | } 159 | const graphic1 = { 160 | id: 'graphic1', 161 | layer: '10', 162 | enable: { 163 | start: '$1.end', 164 | duration: 10, 165 | }, 166 | content: {}, 167 | } 168 | const timeline = fixTimeline([video0, graphic0, graphic1]) 169 | const cache = {} 170 | const resolved = resolveTimeline(timeline, { time: 0, cache }) 171 | 172 | expect(resolved.statistics.resolvingObjectCount).toEqual(3) 173 | expect(resolved.objects['video0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) 174 | expect(resolved.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 20, end: 30 }] }) 175 | expect(resolved.objects['graphic1'].resolved.instances).toHaveLength(0) 176 | 177 | // change the timeline 178 | // @ts-ignore 179 | video0.enable.start = 20 180 | 181 | const resolved2 = resolveTimeline(timeline, { time: 0, cache }) 182 | 183 | expect(resolved2.statistics.resolvingObjectCount).toEqual(2) 184 | expect(resolved2.objects['video0'].resolved).toMatchObject({ instances: [{ start: 20, end: 30 }] }) 185 | expect(resolved2.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 30, end: 40 }] }) 186 | expect(resolved2.objects['graphic1'].resolved.instances).toHaveLength(0) 187 | 188 | // change the layer 189 | video0.layer = '1' 190 | 191 | const resolved3 = resolveTimeline(timeline, { time: 0, cache }) 192 | 193 | expect(resolved3.statistics.resolvingObjectCount).toEqual(3) 194 | expect(resolved3.objects['video0'].resolved).toMatchObject({ instances: [{ start: 20, end: 30 }] }) 195 | expect(resolved3.objects['graphic0'].resolved.instances).toHaveLength(0) 196 | expect(resolved3.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 30, end: 40 }] }) 197 | }) 198 | test('Adding & removing objects', () => { 199 | const timeline = fixTimeline([ 200 | { 201 | id: 'graphic0', 202 | layer: '1', 203 | enable: { 204 | start: '#video0.start + 10', 205 | duration: 10, 206 | }, 207 | content: {}, 208 | }, 209 | { 210 | id: 'graphic1', 211 | layer: '1', 212 | enable: { 213 | start: '#graphic0.start | #video1.start', 214 | duration: 15, 215 | }, 216 | content: {}, 217 | }, 218 | { 219 | id: 'video1', 220 | layer: '2', 221 | enable: { 222 | start: 100, 223 | duration: 10, 224 | }, 225 | content: {}, 226 | classes: ['someVideo'], 227 | }, 228 | ]) 229 | const cache = {} 230 | const resolved = resolveTimeline(timeline, { time: 0, cache }) 231 | 232 | expect(resolved.statistics.resolvingObjectCount).toEqual(3) 233 | expect(resolved.objects['graphic0'].resolved.instances).toHaveLength(0) 234 | expect(resolved.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 100, end: 115 }] }) 235 | expect(resolved.objects['video1'].resolved).toMatchObject({ instances: [{ start: 100, end: 110 }] }) 236 | 237 | // Add an object to the timeline 238 | timeline.push({ 239 | id: 'video0', 240 | layer: '0', 241 | enable: { 242 | start: 0, 243 | end: 100, 244 | }, 245 | content: {}, 246 | }) 247 | const resolved2 = resolveTimeline(timeline, { time: 0, cache }) 248 | expect(resolved2.statistics.resolvingObjectCount).toEqual(3) 249 | expect(resolved2.objects['video0'].resolved).toMatchObject({ instances: [{ start: 0, end: 100 }] }) 250 | expect(resolved2.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) 251 | expect(resolved2.objects['video1'].resolved).toMatchObject({ instances: [{ start: 100, end: 110 }] }) 252 | expect(resolved2.objects['graphic1'].resolved).toMatchObject({ 253 | instances: [ 254 | { start: 20, end: 25 }, 255 | { start: 100, end: 115 }, 256 | ], 257 | }) 258 | 259 | // Remove an object from the timeline: 260 | const index = timeline.findIndex((o) => o.id === 'video1') 261 | timeline.splice(index, 1) 262 | 263 | const resolved3 = resolveTimeline(timeline, { time: 0, cache }) 264 | expect(resolved3.statistics.resolvingObjectCount).toEqual(2) 265 | expect(resolved3.objects['video0'].resolved).toMatchObject({ instances: [{ start: 0, end: 100 }] }) 266 | expect(resolved3.objects['graphic0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) 267 | expect(resolved3.objects['graphic1'].resolved).toMatchObject({ instances: [{ start: 20, end: 25 }] }) 268 | expect(resolved3.objects['video1']).toBeFalsy() 269 | }) 270 | 271 | test('Updating objects', () => { 272 | const timeline = fixTimeline([ 273 | { 274 | id: 'obj0', 275 | layer: '1', 276 | enable: { 277 | start: 0, 278 | }, 279 | content: { 280 | a: 1, 281 | }, 282 | }, 283 | ]) 284 | 285 | const cache = {} 286 | 287 | { 288 | const resolved = resolveTimeline(timeline, { time: 0, cache }) 289 | 290 | expect(resolved.statistics.resolvingObjectCount).toEqual(1) 291 | expect(resolved.objects['obj0'].resolved).toMatchObject({ instances: [{ start: 0, end: null }] }) 292 | 293 | const state = getResolvedState(resolved, 1000) 294 | expect(state.layers['1']).toBeTruthy() 295 | expect(state.layers['1'].content).toEqual({ a: 1 }) 296 | } 297 | 298 | { 299 | const timeline2: TimelineObject[] = clone(timeline) 300 | timeline2[0].content.a = 2 301 | // @ts-ignore illegal, but possible 302 | timeline2[0].otherProperty = 42 303 | 304 | const resolved = resolveTimeline(timeline2, { time: 0, cache }) 305 | 306 | expect(resolved.objects['obj0'].resolved).toMatchObject({ instances: [{ start: 0, end: null }] }) 307 | 308 | const state = getResolvedState(resolved, 1000) 309 | expect(state.layers['1']).toBeTruthy() 310 | expect(state.layers['1'].content).toEqual({ a: 2 }) 311 | // @ts-ignore 312 | expect(state.layers['1'].otherProperty).toEqual(42) 313 | } 314 | }) 315 | 316 | test('Reference group', () => { 317 | const group0 = { 318 | id: 'group0', 319 | layer: '0', 320 | enable: { 321 | start: 10, 322 | duration: 100, 323 | }, 324 | content: {}, 325 | classes: [], 326 | isGroup: true, 327 | children: [ 328 | { 329 | id: 'video0', 330 | layer: '9', 331 | enable: { 332 | start: '0', // 10 333 | duration: 10, 334 | }, 335 | content: {}, 336 | }, 337 | ], 338 | } 339 | const video1 = { 340 | id: 'video1', 341 | layer: '10', 342 | enable: { 343 | start: '#video0.end', 344 | duration: 10, 345 | }, 346 | content: {}, 347 | } 348 | const timeline = fixTimeline([group0, video1]) 349 | const cache = {} 350 | const resolved = resolveTimeline(timeline, { time: 0, cache }) 351 | 352 | expect(resolved.statistics.resolvingObjectCount).toEqual(3) 353 | expect(resolved.objects['group0'].resolved).toMatchObject({ instances: [{ start: 10, end: 110 }] }) 354 | expect(resolved.objects['video0'].resolved).toMatchObject({ instances: [{ start: 10, end: 20 }] }) 355 | expect(resolved.objects['video1'].resolved).toMatchObject({ instances: [{ start: 20, end: 30 }] }) 356 | 357 | // change the group 358 | // @ts-ignore 359 | group0.enable.start = 20 360 | 361 | const resolved2 = resolveTimeline(timeline, { time: 0, cache }) 362 | 363 | expect(resolved2.statistics.resolvingObjectCount).toEqual(3) 364 | expect(resolved2.objects['group0'].resolved).toMatchObject({ instances: [{ start: 20, end: 120 }] }) 365 | expect(resolved2.objects['video0'].resolved).toMatchObject({ instances: [{ start: 20, end: 30 }] }) 366 | expect(resolved2.objects['video1'].resolved).toMatchObject({ instances: [{ start: 30, end: 40 }] }) 367 | }) 368 | }, 369 | { 370 | normal: true, 371 | reversed: true, 372 | cache: false, // no need to run those now 373 | } 374 | ) 375 | -------------------------------------------------------------------------------- /src/__tests__/legacyAPI.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is only for legacy reasons and only used in our internal testing 3 | */ 4 | import { TriggerType, EventType } from './legacyEnums' 5 | export interface TimelineTrigger { 6 | type: TriggerType 7 | value: number | string 8 | } 9 | export interface TimelineObject { 10 | id: ObjectId 11 | trigger: TimelineTrigger 12 | duration?: number | string 13 | LLayer: string | number 14 | content: { 15 | objects?: Array 16 | 17 | keyframes?: Array 18 | // templateData?: any, 19 | 20 | [key: string]: any 21 | } 22 | classes?: string[] 23 | disabled?: boolean 24 | isGroup?: boolean 25 | repeating?: boolean 26 | priority?: number 27 | externalFunction?: string 28 | } 29 | export interface TimelineGroup extends TimelineObject { 30 | resolved: ResolvedDetails 31 | parent?: TimelineGroup 32 | } 33 | export type TimeMaybe = number | null 34 | 35 | export type StartTime = number | null 36 | export type EndTime = number | null 37 | export type Duration = number | null 38 | export type SomeTime = number 39 | 40 | export type ObjectId = string 41 | 42 | export interface TimelineEvent { 43 | type: EventType 44 | time: SomeTime 45 | obj: TimelineObject 46 | kf?: TimelineResolvedKeyframe 47 | } 48 | export interface TimelineKeyframe { 49 | id: string 50 | trigger: { 51 | type: TriggerType 52 | value: number | string 53 | } 54 | duration?: number | string 55 | content?: { 56 | // templateData?: any, 57 | [key: string]: any 58 | } 59 | classes?: string[] 60 | } 61 | export interface UnresolvedLogicObject { 62 | prevOnTimeline?: string | boolean | null 63 | obj: TimelineResolvedObject 64 | } 65 | export interface TimelineResolvedObject extends TimelineObject { 66 | resolved: ResolvedDetails 67 | parent?: TimelineGroup 68 | } 69 | export interface TimelineResolvedKeyframe extends TimelineKeyframe { 70 | resolved: ResolvedDetails 71 | parent?: TimelineResolvedObject 72 | } 73 | export interface ResolvedDetails { 74 | startTime?: StartTime 75 | endTime?: EndTime 76 | innerStartTime?: StartTime 77 | innerEndTime?: EndTime 78 | innerDuration?: Duration 79 | outerDuration?: Duration 80 | 81 | parentStart?: StartTime 82 | 83 | parentId?: ObjectId 84 | disabled?: boolean 85 | 86 | referredObjectIds?: Array | null 87 | 88 | repeatingStartTime?: StartTime 89 | 90 | templateData?: any 91 | developed?: boolean 92 | 93 | [key: string]: any 94 | } 95 | export interface ResolvedObjectId { 96 | id: string 97 | hook: string 98 | } 99 | export interface ResolvedTimeline { 100 | resolved: Array 101 | unresolved: Array 102 | } 103 | export interface DevelopedTimeline { 104 | resolved: Array 105 | unresolved: Array 106 | groups: Array 107 | } 108 | 109 | export interface TimelineState { 110 | time: SomeTime 111 | GLayers: { 112 | [GLayer: string]: TimelineResolvedObject 113 | } 114 | LLayers: { 115 | [LLayer: string]: TimelineResolvedObject 116 | } 117 | } 118 | export interface ExternalFunctions { 119 | [fcnName: string]: (obj: TimelineResolvedObject, state: TimelineState, tld: DevelopedTimeline) => boolean 120 | } 121 | export type UnresolvedTimeline = Array 122 | 123 | export interface ResolvedObjectsStore { 124 | [id: string]: TimelineResolvedObject | TimelineResolvedKeyframe 125 | } 126 | export interface ResolvedObjectTouches { 127 | [key: string]: number 128 | } 129 | 130 | export type Expression = number | string | ExpressionObj 131 | export interface ExpressionObj { 132 | l: Expression 133 | o: string 134 | r: Expression 135 | } 136 | export interface Filter { 137 | startTime?: StartTime 138 | endTime?: EndTime 139 | } 140 | export type WhosAskingTrace = string[] 141 | export type objAttributeFunction = ( 142 | objId: string, 143 | hook: 'start' | 'end' | 'duration' | 'parentStart', 144 | whosAsking: WhosAskingTrace, 145 | supressAlreadyAskedWarning?: boolean 146 | ) => number | null 147 | 148 | // ----------- 149 | export interface ResolveOptions { 150 | /** The base time to use when resolving. Usually you want to input the current time (Date.now()) here. */ 151 | time: SomeTime 152 | /** Limits the number of repeating objects in the future. 153 | * Defaults to 2, which means that the current one and the next will be resolved. 154 | */ 155 | limit?: number 156 | } 157 | -------------------------------------------------------------------------------- /src/__tests__/legacyEnums.ts: -------------------------------------------------------------------------------- 1 | export enum TriggerType { 2 | TIME_ABSOLUTE = 0, // The object is placed on an absolute time on the timeline 3 | TIME_RELATIVE = 1, // The object is defined by an expression defining the time relative to other objects 4 | 5 | // To be implemented (and might never be) 6 | // EVENT = 2, // the object is not on the timeline, but can be triggered typically from an external source 7 | 8 | LOGICAL = 3, // the object is defined by a logical expression, if resolved to true, then object is present on current time., 9 | } 10 | 11 | export enum EventType { 12 | START = 0, 13 | END = 1, 14 | KEYFRAME = 2, 15 | } 16 | 17 | export enum TraceLevel { 18 | ERRORS = 0, 19 | INFO = 1, 20 | TRACE = 2, 21 | } 22 | 23 | export const Enums = { 24 | TriggerType: TriggerType, 25 | TimelineEventType: EventType, 26 | TraceLevel: TraceLevel, 27 | } 28 | -------------------------------------------------------------------------------- /src/__tests__/performance.spec.ts: -------------------------------------------------------------------------------- 1 | import { ticTocPrint } from '../resolver/lib/performance' 2 | import { doPerformanceTest, round, setupPerformanceTests } from './performance' 3 | 4 | const TIMEOUT_TIME = 10 * 1000 5 | const TEST_COUNT = 500 6 | 7 | beforeAll(() => { 8 | setupPerformanceTests(false) // set to true to enable performance debugging 9 | }) 10 | describe('performance', () => { 11 | test( 12 | 'performance test, no cache', 13 | () => { 14 | const { sortedTimes, executionTimeAvg } = doPerformanceTest(TEST_COUNT, TIMEOUT_TIME, false) 15 | console.log( 16 | `No Cache: Average time of execution: ${round(executionTimeAvg)} ms\n` + 17 | 'Worst 5:\n' + 18 | sortedTimes 19 | .slice(-5) 20 | .map((t) => `${t.key}: ${round(t.time)} ms`) 21 | .join('\n') 22 | ) 23 | 24 | expect(executionTimeAvg).toBeLessThan(30) // it's ~15ms in GH actions 25 | ticTocPrint() 26 | }, 27 | TIMEOUT_TIME 28 | ) 29 | test( 30 | 'performance test, with cache', 31 | () => { 32 | const { sortedTimes, executionTimeAvg } = doPerformanceTest(TEST_COUNT, TIMEOUT_TIME, true) 33 | console.log( 34 | `With cache: Average time of execution: ${round(executionTimeAvg)} ms\n` + 35 | 'Worst 5:\n' + 36 | sortedTimes 37 | .slice(-5) 38 | .map((t) => `${t.key}: ${round(t.time)} ms`) 39 | .join('\n') 40 | ) 41 | 42 | expect(executionTimeAvg).toBeLessThan(20) // it's ~10ms in GH actions 43 | ticTocPrint() 44 | }, 45 | TIMEOUT_TIME 46 | ) 47 | }) 48 | -------------------------------------------------------------------------------- /src/__tests__/performance.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks' 2 | import { generateTimeline } from './timelineGenerator' 3 | import { ResolveOptions, ResolvedTimeline, TimelineObject } from '../api' 4 | import { getResolvedState, resolveTimeline } from '..' 5 | import { sortBy } from '../resolver/lib/lib' 6 | import { activatePerformanceDebugging, setPerformanceTimeFunction } from '../resolver/lib/performance' 7 | 8 | export function setupPerformanceTests(activateDebugging: boolean): void { 9 | activatePerformanceDebugging(activateDebugging) 10 | // eslint-disable-next-line @typescript-eslint/unbound-method 11 | setPerformanceTimeFunction(performance.now) 12 | } 13 | 14 | const startTimer = () => { 15 | const startTime = process.hrtime() 16 | return { 17 | stop: () => { 18 | const end = process.hrtime(startTime) 19 | return end[0] + end[1] / 1000000 20 | }, 21 | } 22 | } 23 | 24 | export const round = (num: number): number => { 25 | return Math.floor(num * 100) / 100 26 | } 27 | 28 | export const doPerformanceTest = ( 29 | testCount: number, 30 | timeoutTime: number, 31 | useCache: boolean 32 | ): { 33 | errorCount: number 34 | sortedTimes: { 35 | time: number 36 | key: string 37 | }[] 38 | executionTimeAvg: number 39 | } => { 40 | let seed = -1 41 | 42 | let executionTimeAvg = 0 43 | let executionTimeCount = 0 44 | let errorCount = 0 45 | 46 | const cache = {} 47 | 48 | const stats: { [key: string]: number } = {} 49 | 50 | const testCountMax = testCount * 2 51 | 52 | const startTime = Date.now() 53 | for (let i = 0; i < testCountMax; i++) { 54 | if (executionTimeCount >= testCount) break 55 | const totalDuration = Date.now() - startTime 56 | if (totalDuration >= timeoutTime) { 57 | throw new Error(`Tests took too long (${totalDuration}ms)`) 58 | } 59 | 60 | seed++ 61 | 62 | const timeline: Array = generateTimeline(seed, 100, 3) 63 | 64 | const options: ResolveOptions = { 65 | time: 0, 66 | cache: useCache ? cache : undefined, 67 | } 68 | 69 | // Start the timer: 70 | 71 | let resolvedTimeline: ResolvedTimeline | undefined = undefined 72 | let useTest = false 73 | let testDuration = 0 74 | try { 75 | const timer = startTimer() 76 | // Resolve the timeline 77 | resolvedTimeline = resolveTimeline(timeline, options) 78 | // Calculate the state at a certain time: 79 | const state0 = getResolvedState(resolvedTimeline, 15) 80 | const state1 = getResolvedState(resolvedTimeline, 20) 81 | const state2 = getResolvedState(resolvedTimeline, 50) 82 | 83 | // Resolve the timeline again 84 | const resolvedTimeline2 = resolveTimeline(timeline, options) 85 | const state20 = getResolvedState(resolvedTimeline2, 15) 86 | 87 | // Stop the timer: 88 | testDuration = timer.stop() 89 | 90 | if (!resolvedTimeline) throw new Error(`resolvedTimeline is falsy`) 91 | if (!state0) throw new Error(`state0 is falsy`) 92 | if (!state1) throw new Error(`state1 is falsy`) 93 | if (!state2) throw new Error(`state2 is falsy`) 94 | if (!state20) throw new Error(`state20 is falsy`) 95 | 96 | useTest = true 97 | } catch (e) { 98 | errorCount++ 99 | if (/circular/.test(`${e}`)) { 100 | // Unable to resolve timeline due to circular references 101 | // ignore, we'll just use the next one instead. 102 | } else { 103 | throw e 104 | } 105 | } 106 | 107 | if (useTest) { 108 | if (testDuration > 500) throw new Error(`Test took too long (${testDuration}ms)`) 109 | executionTimeAvg += testDuration 110 | executionTimeCount++ 111 | stats['seed ' + seed] = testDuration 112 | } 113 | } 114 | executionTimeAvg /= executionTimeCount 115 | 116 | const sortedTimes = sortBy( 117 | Object.entries(stats).map(([key, time]) => { 118 | return { time, key } 119 | }), 120 | (t) => t.time 121 | ) 122 | 123 | return { 124 | errorCount, 125 | sortedTimes, 126 | executionTimeAvg, 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/__tests__/testlib.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-export, jest/valid-title, jest/expect-expect, jest/no-standalone-expect, jest/no-done-callback */ 2 | 3 | import { ResolverCache, TimelineObject } from '../api' 4 | import * as Timeline from '..' 5 | 6 | interface Test { 7 | (name: string, fn?: ProvidesCallback, timeout?: number): void 8 | 9 | only: Test 10 | skip: Test 11 | todo: Test 12 | concurrent: Test 13 | } 14 | interface DoneCallback { 15 | (...args: any[]): any 16 | fail(error?: string | { message: string }): any 17 | } 18 | type ProvidesCallback = (cb: DoneCallback) => void 19 | 20 | // export type Test = (testName: string, cb: (cache: any | undefined) => void | Promise) => void 21 | export type FixTimeline = (timeline: TimelineObject[]) => TimelineObject[] 22 | export type GetCache = () => Partial | undefined 23 | 24 | function makeTest(setupTest: (test: jest.It, name: string, fn?: ProvidesCallback, timeout?: number) => void): jest.It { 25 | const testFunction: jest.It = (name, fn, timeout) => setupTest(test, name, fn, timeout) 26 | testFunction.only = ((name, fn, timeout) => setupTest(test.only, name, fn, timeout)) as jest.It 27 | testFunction.skip = ((name, fn, timeout) => setupTest(test.skip, name, fn, timeout)) as jest.It 28 | testFunction.todo = ((name, fn, timeout) => setupTest(test.todo, name, fn, timeout)) as jest.It 29 | testFunction.concurrent = ((name, fn, timeout) => setupTest(test.concurrent, name, fn, timeout)) as jest.It 30 | testFunction.failing = ((name, fn, timeout) => setupTest(test.failing, name, fn, timeout)) as jest.It 31 | testFunction.each = test.each 32 | 33 | return testFunction 34 | } 35 | 36 | /** Runs the tests with variations, such as reversing the objects, and with a cache */ 37 | export function describeVariants( 38 | describeName: string, 39 | setupTests: (test: Test, fixTimeline: FixTimeline, getCache: GetCache) => void, 40 | options: { 41 | normal?: boolean 42 | reversed?: boolean 43 | cache?: boolean 44 | } 45 | ): void { 46 | if (options.normal) { 47 | // First run the tests normally: 48 | describe(describeName, () => { 49 | const getCache = literal(() => undefined) 50 | const fixTimelineNormal = jest.fn((timeline: TimelineObject[]) => timeline) 51 | 52 | const testNormal = makeTest((test, name, fn, timeout) => { 53 | test(name, fn, timeout) 54 | afterEach(() => { 55 | expect(fixTimelineNormal).toHaveBeenCalled() 56 | }) 57 | }) 58 | 59 | setupTests(testNormal, fixTimelineNormal, getCache) 60 | }) 61 | } 62 | 63 | if (options.reversed) { 64 | // Run tests with reversed timeline: 65 | describe(describeName, () => { 66 | const getCache = literal(() => undefined) 67 | const fixTimelineReversed = (timeline: TimelineObject[]) => reverseTimeline(timeline) 68 | 69 | const testNormal = makeTest((test, name, fn, timeout) => { 70 | test(`Reversed: ${name}`, fn, timeout) 71 | }) 72 | setupTests(testNormal, fixTimelineReversed, getCache) 73 | }) 74 | } 75 | 76 | if (options.cache) { 77 | // Run tests with cache: 78 | describe(describeName, () => { 79 | const c = { 80 | cache: {} as Partial, 81 | } 82 | let reverse = false 83 | 84 | const getCache = jest.fn(literal(() => c.cache)) 85 | const fixTimelineNormal = (timeline: TimelineObject[]) => { 86 | if (reverse) { 87 | return reverseTimeline(timeline) 88 | } else { 89 | return timeline 90 | } 91 | } 92 | 93 | const testWithCaches = makeTest((test, name, fn, timeout) => { 94 | test( 95 | `Test cache: ${name}`, 96 | (...args: any[]) => { 97 | if (!fn) return 98 | 99 | const cb = args[0] // Note: we can't use the cb parameter directly, because of some jest magic 100 | 101 | // Reset data 102 | c.cache = {} 103 | reverse = false 104 | // First run the test with an empty cache 105 | const resolved0 = fn(cb) 106 | 107 | // Then run the test again with the previous cache 108 | const resolved1 = fn(cb) 109 | 110 | // Then reverse the timeline and run with the previous cache 111 | reverse = true 112 | const resolved2 = fn(cb) 113 | 114 | expect(resolved0).toEqual(resolved1) 115 | expect(resolved1).toEqual(resolved2) 116 | }, 117 | timeout ? timeout * 3 : undefined 118 | ) 119 | afterEach(() => { 120 | expect(getCache).toHaveBeenCalled() 121 | }) 122 | }) 123 | setupTests(testWithCaches, fixTimelineNormal, getCache) 124 | }) 125 | } 126 | } 127 | describeVariants.only = describe.only 128 | describeVariants.skip = describe.skip 129 | 130 | function literal(o: T) { 131 | return o 132 | } 133 | function reverseTimeline(tl: TimelineObject[]): TimelineObject[] { 134 | tl.reverse() 135 | for (const obj of tl) { 136 | if (obj.children) { 137 | reverseTimeline(obj.children) 138 | } 139 | if (obj.keyframes) { 140 | obj.keyframes.reverse() 141 | } 142 | } 143 | 144 | return tl 145 | } 146 | 147 | /** resolveTimeline, with an extra check that the timeline is serializable */ 148 | export function resolveTimeline( 149 | ...args: Parameters 150 | ): ReturnType { 151 | const tl = Timeline.resolveTimeline(...args) 152 | expect(() => JSON.stringify(tl)).not.toThrow() // test that it's serializable 153 | return tl 154 | } 155 | /** getResolvedState, with an extra check that the state is serializable */ 156 | export function getResolvedState( 157 | ...args: Parameters 158 | ): ReturnType { 159 | const state = Timeline.getResolvedState(...args) 160 | expect(() => JSON.stringify(state)).not.toThrow() // test that it's serializable 161 | return state 162 | } 163 | -------------------------------------------------------------------------------- /src/__tests__/timelineGenerator.ts: -------------------------------------------------------------------------------- 1 | import { TimelineObject } from '../api' 2 | 3 | /** Returns a timeline, to be used in tests */ 4 | export function generateTimeline(seed: number, maxCount: number, maxGroupDepth: number): TimelineObject[] { 5 | const random = new Random(seed) 6 | 7 | const timeline: TimelineObject[] = [] 8 | 9 | const ref: TimelineObject[][] = [timeline] 10 | 11 | const layers: string[] = [] 12 | const layerCount = Math.ceil(random.get() * maxCount * 0.3) 13 | for (let i = 0; i < layerCount; i++) { 14 | layers.push('layer' + random.getInt(10000)) 15 | } 16 | const objectIds: string[] = [] 17 | 18 | let count = 0 19 | while (count < maxCount) { 20 | count++ 21 | 22 | const refObj = objectIds[random.getInt(objectIds.length)] 23 | const enable: TimelineObject['enable'] = 24 | !refObj || random.get() < 0.25 25 | ? { start: `${random.getInt(1000)}` } 26 | : random.get() < 0.25 27 | ? { start: `#${objectIds}` } 28 | : random.get() < 0.33 29 | ? { start: `#${objectIds} + ${random.getInt(100)}` } 30 | : random.get() < 0.5 31 | ? { while: `#${objectIds} + ${random.getInt(100)}` } 32 | : { while: `#${objectIds}` } 33 | if (random.get() < 0.1) { 34 | enable.repeating = random.getInt(400) 35 | } 36 | 37 | const obj: TimelineObject = { 38 | id: 'aaaaa' + count, 39 | content: {}, 40 | enable: enable, 41 | layer: layers[random.getInt(layers.length)], 42 | } 43 | const collection: TimelineObject[] = ref[ref.length - 1] 44 | 45 | if (random.get() > 0.2 && ref.length < maxGroupDepth) { 46 | // Create a new group and continue in it 47 | obj.isGroup = true 48 | obj.children = [] 49 | ref.push(obj.children) 50 | } else if (ref.length > 1 && random.get() < 0.2) { 51 | // go out of the group 52 | ref.pop() 53 | } else { 54 | // nothing 55 | } 56 | collection.push(obj) 57 | } 58 | return timeline 59 | } 60 | 61 | // Park-Miller-Carta Pseudo-Random Number Generator, https://gist.github.com/blixt/f17b47c62508be59987b 62 | class Random { 63 | private _seed: number 64 | constructor(seed: number) { 65 | this._seed = seed % 2147483647 66 | if (this._seed <= 0) this._seed += 2147483646 67 | } 68 | /** 69 | * Returns a pseudo-random value between 1 and 2^32 - 2. 70 | */ 71 | private next() { 72 | return (this._seed = (this._seed * 16807) % 2147483647) 73 | } 74 | /** 75 | * Returns a pseudo-random floating point number in range [0, 1). 76 | */ 77 | get() { 78 | // We know that result of next() will be 1 to 2147483646 (inclusive). 79 | return (this.next() - 1) / 2147483646 80 | } 81 | getInt(max: number) { 82 | return Math.floor(this.get() * max) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/__tests__/validate.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | validateObject, 3 | validateKeyframe, 4 | validateTimeline, 5 | validateReferenceString, 6 | TimelineObject, 7 | TimelineEnable, 8 | TimelineKeyframe, 9 | } from '..' 10 | import { clone } from '../resolver/lib/lib' 11 | 12 | describe('validate', () => { 13 | const obj: TimelineObject = { 14 | id: 'obj0', 15 | enable: { 16 | start: 10, 17 | }, 18 | layer: '1', 19 | content: {}, 20 | } 21 | const keyframe: TimelineKeyframe = { 22 | id: 'kf0', 23 | enable: { 24 | start: 10, 25 | end: 14, 26 | }, 27 | content: {}, 28 | } 29 | const timeline: TimelineObject[] = [ 30 | { 31 | id: 'obj1', 32 | enable: { 33 | start: 1000, 34 | }, 35 | layer: '1', 36 | content: {}, 37 | }, 38 | { 39 | id: 'obj2', 40 | enable: { 41 | while: 1, 42 | }, 43 | layer: '1', 44 | content: {}, 45 | }, 46 | ] 47 | test('validateObject', () => { 48 | expect(() => { 49 | validateObject(obj, true) 50 | }).not.toThrow() 51 | 52 | expect(() => { 53 | // @ts-ignore 54 | validateObject(undefined, true) 55 | }).toThrow() 56 | expect(() => { 57 | // @ts-ignore 58 | validateObject(1337, true) 59 | }).toThrow() 60 | 61 | expect(() => { 62 | const o = clone(obj) 63 | // @ts-expect-error 64 | delete o.id 65 | validateObject(o, true) 66 | }).toThrow() 67 | 68 | expect(() => { 69 | const o = clone(obj) 70 | // @ts-ignore 71 | o.id = 1337 72 | validateObject(o, true) 73 | }).toThrow() 74 | 75 | expect(() => { 76 | const o = clone(obj) 77 | // @ts-expect-error 78 | delete o.enable 79 | validateObject(o, true) 80 | }).toThrow() 81 | 82 | expect(() => { 83 | const o = clone(obj) 84 | o.enable = clone(o.enable) as TimelineEnable 85 | o.enable.while = 32 86 | validateObject(o, true) 87 | }).toThrow() 88 | 89 | expect(() => { 90 | const o = clone(obj) 91 | o.enable = [o.enable as TimelineEnable] 92 | validateObject(o, true) 93 | }).not.toThrow() 94 | 95 | expect(() => { 96 | const o = clone(obj) 97 | o.enable = { 98 | start: 10, 99 | end: 32, 100 | duration: 22, 101 | } 102 | validateObject(o, true) 103 | }).toThrow() 104 | 105 | expect(() => { 106 | const o = clone(obj) 107 | o.enable = { 108 | while: 1, 109 | end: 32, 110 | } 111 | validateObject(o, true) 112 | }).toThrow() 113 | 114 | expect(() => { 115 | const o = clone(obj) 116 | o.enable = { 117 | while: 1, 118 | duration: 10, 119 | } 120 | validateObject(o, true) 121 | }).toThrow() 122 | 123 | expect(() => { 124 | const o = clone(obj) 125 | // @ts-expect-error 126 | delete o.layer 127 | validateObject(o, true) 128 | }).toThrow() 129 | 130 | expect(() => { 131 | const o = clone(obj) 132 | // @ts-expect-error 133 | delete o.content 134 | validateObject(o, true) 135 | }).toThrow() 136 | 137 | expect(() => { 138 | const o = clone(obj) 139 | // @ts-ignore 140 | o.classes = ['123', 124] 141 | validateObject(o, true) 142 | }).toThrow() 143 | 144 | expect(() => { 145 | const o = clone(obj) 146 | // @ts-ignore 147 | o.priority = '2' 148 | validateObject(o, true) 149 | }).toThrow() 150 | 151 | expect(() => { 152 | const o = clone(obj) 153 | o.children = clone(timeline) 154 | validateObject(o, true) 155 | }).toThrow() 156 | 157 | expect(() => { 158 | const o = clone(obj) 159 | o.children = clone(timeline) 160 | o.isGroup = true 161 | validateObject(o, true) 162 | }).not.toThrow() 163 | 164 | expect(() => { 165 | const o = clone(obj) 166 | o.children = [clone(timeline[0]), clone(timeline[1])] 167 | o.isGroup = true 168 | // @ts-ignore 169 | o.children[0].id = 123 170 | validateObject(o, true) 171 | }).toThrow() 172 | }) 173 | test('validateKeyframe', () => { 174 | expect(() => { 175 | validateKeyframe(keyframe, true) 176 | }).not.toThrow() 177 | 178 | expect(() => { 179 | // @ts-ignore 180 | validateKeyframe(undefined, true) 181 | }).toThrow() 182 | 183 | expect(() => { 184 | // @ts-ignore 185 | validateKeyframe('abc', true) 186 | }).toThrow() 187 | 188 | expect(() => { 189 | const o = clone(keyframe) 190 | // @ts-expect-error 191 | delete o.id 192 | validateKeyframe(o, true) 193 | }).toThrow() 194 | 195 | expect(() => { 196 | const o = clone(keyframe) 197 | // @ts-ignore 198 | o.id = 12 199 | validateKeyframe(o, true) 200 | }).toThrow() 201 | 202 | expect(() => { 203 | const o = clone(keyframe) 204 | // @ts-expect-error 205 | delete o.enable 206 | validateKeyframe(o, true) 207 | }).toThrow() 208 | 209 | expect(() => { 210 | const o = clone(keyframe) 211 | o.enable = clone(o.enable) as TimelineEnable 212 | o.enable.while = 32 213 | validateKeyframe(o, true) 214 | }).toThrow() 215 | 216 | expect(() => { 217 | const o = clone(keyframe) 218 | o.enable = { 219 | start: 10, 220 | end: 32, 221 | duration: 22, 222 | } 223 | validateKeyframe(o, true) 224 | }).toThrow() 225 | 226 | expect(() => { 227 | const o = clone(keyframe) 228 | o.enable = [ 229 | { 230 | start: 10, 231 | end: 32, 232 | duration: 22, 233 | }, 234 | ] 235 | validateKeyframe(o, true) 236 | }).toThrow() 237 | 238 | expect(() => { 239 | const o = clone(keyframe) 240 | o.enable = { 241 | while: 1, 242 | end: 32, 243 | } 244 | validateKeyframe(o, true) 245 | }).toThrow() 246 | 247 | expect(() => { 248 | const o = clone(keyframe) 249 | o.enable = { 250 | while: 1, 251 | duration: 10, 252 | } 253 | validateKeyframe(o, true) 254 | }).toThrow() 255 | 256 | expect(() => { 257 | const o = clone(keyframe) 258 | o.enable = clone(o.enable) as TimelineEnable 259 | delete o.enable.start 260 | validateKeyframe(o, true) 261 | }).toThrow() 262 | 263 | expect(() => { 264 | const o = clone(keyframe) 265 | // @ts-expect-error 266 | delete o.content 267 | validateKeyframe(o, true) 268 | }).toThrow() 269 | 270 | expect(() => { 271 | const o = clone(keyframe) 272 | // @ts-ignore 273 | o.classes = ['123', 124] 274 | validateKeyframe(o, true) 275 | }).toThrow() 276 | 277 | expect(() => { 278 | const o = clone(obj) 279 | o.keyframes = [clone(keyframe)] 280 | validateObject(o, true) 281 | }).not.toThrow() 282 | 283 | expect(() => { 284 | const o = clone(obj) 285 | o.keyframes = [clone(keyframe)] 286 | // @ts-ignore 287 | o.keyframes[0].id = 13 288 | validateObject(o, true) 289 | }).toThrow() 290 | 291 | expect(() => { 292 | const o = clone(obj) 293 | o.keyframes = [clone(keyframe)] 294 | // @ts-ignore 295 | o.keyframes[0].id = obj.id 296 | validateObject(o, true) 297 | }).toThrow() 298 | 299 | expect(() => { 300 | const o = clone(obj) 301 | // @ts-ignore 302 | o.classes = [123] 303 | validateObject(o, true) 304 | }).toThrow() 305 | }) 306 | test('validateTimeline', () => { 307 | expect(() => { 308 | const tl = clone(timeline) 309 | validateTimeline(tl, false) 310 | }).not.toThrow() 311 | 312 | expect(() => { 313 | const tl = clone(timeline) 314 | tl[1].id = tl[0].id 315 | validateTimeline(tl, false) 316 | }).toThrow() 317 | }) 318 | test('validateReferenceString', () => { 319 | expect(() => validateReferenceString('')).not.toThrow() 320 | expect(() => validateReferenceString('test')).not.toThrow() 321 | expect(() => validateReferenceString('abcABC123_')).not.toThrow() 322 | expect(() => validateReferenceString('_¤"\'£€\\,;:¨~')).not.toThrow() 323 | 324 | expect(() => validateReferenceString('test-1')).toThrow() 325 | expect(() => validateReferenceString('test+1')).toThrow() 326 | expect(() => validateReferenceString('test/1')).toThrow() 327 | expect(() => validateReferenceString('test*1')).toThrow() 328 | expect(() => validateReferenceString('test%1')).toThrow() 329 | expect(() => validateReferenceString('test&1')).toThrow() 330 | expect(() => validateReferenceString('test|1')).toThrow() 331 | expect(() => validateReferenceString('test!')).toThrow() 332 | expect(() => validateReferenceString('test(')).toThrow() 333 | expect(() => validateReferenceString('test)')).toThrow() 334 | expect(() => validateReferenceString('#test')).toThrow() // a reference to an object id 335 | expect(() => validateReferenceString('.test')).toThrow() // a reference to an object class 336 | expect(() => validateReferenceString('$test')).toThrow() // a reference to an object layer 337 | 338 | // These aren't currently in use anywhere, but might be so in the future: 339 | expect(() => validateReferenceString('test§', true)).toThrow() 340 | expect(() => validateReferenceString('test^', true)).toThrow() 341 | expect(() => validateReferenceString('test?', true)).toThrow() 342 | expect(() => validateReferenceString('test=', true)).toThrow() 343 | expect(() => validateReferenceString('test{', true)).toThrow() 344 | expect(() => validateReferenceString('test}', true)).toThrow() 345 | expect(() => validateReferenceString('test[', true)).toThrow() 346 | expect(() => validateReferenceString('test]', true)).toThrow() 347 | }) 348 | test('invalid id-strings', () => { 349 | expect(() => { 350 | const tl = clone(timeline) 351 | tl[0] = clone(tl[0]) 352 | tl[0].id = 'obj-1' 353 | validateTimeline(tl, false) 354 | }).toThrow(/id/) 355 | 356 | expect(() => { 357 | const tl = clone(timeline) 358 | tl[0] = clone(tl[0]) 359 | 360 | tl[0].classes = ['class-1'] 361 | validateTimeline(tl, false) 362 | }).toThrow(/class/) 363 | expect(() => { 364 | const tl = clone(timeline) 365 | tl[0] = clone(tl[0]) 366 | 367 | tl[0].layer = 'layer-1' 368 | validateTimeline(tl, false) 369 | }).toThrow(/layer/) 370 | }) 371 | }) 372 | -------------------------------------------------------------------------------- /src/api/expression.ts: -------------------------------------------------------------------------------- 1 | /** An Expression can be: 2 | * * An absolute value (number) 3 | * * An expression describing a relationship (string), like "#A.start + 10" 4 | * * An expression object, like {l: "#A.start", o: '+', r: '10'} 5 | */ 6 | export type Expression = number | string | ExpressionObj | null 7 | 8 | export type ExpressionOperator = '+' | '-' | '*' | '/' | '&' | '|' | '!' | '%' 9 | 10 | /** An ExpressionObj represents a mathematic expression. Eg. "1 + 2" === { l: "1", o: "+", r: "2"} */ 11 | export interface ExpressionObj { 12 | /** Left-side operand of the expression */ 13 | l: Expression 14 | /** Operator of the expression */ 15 | o: ExpressionOperator 16 | /** Right-side operand of the expression */ 17 | r: Expression 18 | } 19 | export interface InnerExpression { 20 | inner: any[] 21 | rest: string[] 22 | } 23 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './expression' 2 | export * from './resolvedTimeline' 3 | export * from './resolver' 4 | export * from './state' 5 | export * from './timeline' 6 | export * from './types' 7 | -------------------------------------------------------------------------------- /src/api/resolvedTimeline.ts: -------------------------------------------------------------------------------- 1 | import { Content, TimelineObject } from './timeline' 2 | import { InstanceId, Reference, Time } from './types' 3 | 4 | /** 5 | * The ResolvedTimeline contains all objects on the timeline, resolved. 6 | * All references and all conflicts have been resolved. 7 | * The absolute times of all objects can be found in objects[id].resolved.instances. 8 | * 9 | * To retrieve a state at a certain time, use getResolvedState(resolvedTimeline, time). 10 | * 11 | * Note: If `limitTime` was specified in the ResolveOptions, 12 | * the ResolvedTimeline is only valid up until that time and 13 | * needs to be re-resolved afterwards. 14 | */ 15 | export interface ResolvedTimeline { 16 | /** The options used to resolve the timeline */ 17 | // options: ResolveOptions 18 | /** Map of all objects on timeline */ 19 | objects: ResolvedTimelineObjects 20 | /** Map of all classes on timeline, maps className to object ids */ 21 | classes: { [className: string]: string[] } 22 | /** Map of the object ids, per layer */ 23 | layers: { [layer: string]: string[] } 24 | 25 | // states: AllStates 26 | nextEvents: NextEvent[] 27 | 28 | statistics: { 29 | /** Number of timeline objects (including keyframes) in the timeline */ 30 | totalCount: number 31 | 32 | /** Number of resolved instances */ 33 | resolvedInstanceCount: number 34 | /** Number of resolved objects */ 35 | resolvedObjectCount: number 36 | /** Number of resolved groups */ 37 | resolvedGroupCount: number 38 | /** Number of resolved keyframes */ 39 | resolvedKeyframeCount: number 40 | 41 | /** How many objects (including keyframes) where actually resolved (is affected when using cache) */ 42 | resolvingObjectCount: number 43 | 44 | /** 45 | * How many times the objects where resolved. 46 | * If there are deep/recursive references in the timline, 47 | * the resolver might resolve an object multiple times in order to fully resolve the timeline. 48 | * (is affected when using cache) 49 | */ 50 | resolvingCount: number 51 | 52 | /** If traceResolving option is enabled, will contain a trace of the steps the resolver did while resolving */ 53 | resolveTrace: string[] 54 | } 55 | /** Is set if there was an error during Resolving and options.dontThrowOnError is set. */ 56 | error?: Error 57 | } 58 | export interface ResolvedTimelineObjects { 59 | [id: string]: ResolvedTimelineObject 60 | } 61 | export interface ResolvedTimelineObject extends TimelineObject { 62 | resolved: { 63 | /** Instances of the object on the timeline */ 64 | instances: TimelineObjectInstance[] 65 | /** A number that increases the more levels inside of a group the objects is. 0 = no parent */ 66 | levelDeep: number 67 | /** Id of the parent object (for children in groups or keyframes) */ 68 | parentId: string | undefined 69 | /** True if object is a keyframe */ 70 | isKeyframe: boolean 71 | 72 | /** Is set to true while object is being resolved (to prevent circular references) */ 73 | resolving: boolean 74 | 75 | /** Is set to true when object is resolved first time, and isn't reset thereafter */ 76 | firstResolved: boolean 77 | 78 | /** Is set to true when object's references has been resolved */ 79 | resolvedReferences: boolean 80 | /** Is set to true when object's conflicts has been resolved */ 81 | resolvedConflicts: boolean 82 | 83 | /** True if object is referencing itself (only directly, not indirectly via another object) */ 84 | isSelfReferencing: boolean 85 | /** Ids of all other objects that directly affects this object (ie through direct reference, classes, etc) */ 86 | directReferences: Reference[] 87 | } 88 | } 89 | export interface InstanceBase { 90 | /** The start time of the instance */ 91 | start: Time 92 | /** The end time of the instance (null = infinite) */ 93 | end: Time | null 94 | } 95 | export interface TimelineObjectInstance extends InstanceBase { 96 | /** id of the instance (unique) */ 97 | id: InstanceId 98 | /** if true, the instance starts from the beginning of time */ 99 | isFirst?: boolean 100 | 101 | /** The original start time of the instance (if an instance is split or capped, the original start time is retained in here). 102 | * If undefined, fallback to .start 103 | */ 104 | originalStart?: Time 105 | /** The original end time of the instance (if an instance is split or capped, the original end time is retained in here) 106 | * If undefined, fallback to .end 107 | */ 108 | originalEnd?: Time | null 109 | 110 | /** array of the id of the referenced objects */ 111 | references: Reference[] 112 | 113 | /** If set, tells the cap of the parent. The instance will always be capped inside this. */ 114 | caps?: Cap[] 115 | /** If the instance was generated from another instance, reference to the original */ 116 | fromInstanceId?: string 117 | } 118 | export interface Cap { 119 | id: InstanceId // id of the parent instance 120 | start: Time 121 | end: Time | null 122 | } 123 | export interface NextEvent { 124 | type: EventType 125 | time: Time 126 | objId: string 127 | } 128 | export enum EventType { 129 | START = 0, 130 | END = 1, 131 | KEYFRAME = 2, 132 | } 133 | 134 | export interface AllStates { 135 | [layer: string]: { 136 | [time: string]: ResolvedTimelineObjectInstanceKeyframe[] | null 137 | } 138 | } 139 | export interface ResolvedTimelineObjectInstanceKeyframe 140 | extends ResolvedTimelineObjectInstance { 141 | isKeyframe?: boolean 142 | } 143 | export interface ResolvedTimelineObjectInstance 144 | extends ResolvedTimelineObject { 145 | instance: TimelineObjectInstance 146 | } 147 | -------------------------------------------------------------------------------- /src/api/resolver.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedTimelineObjects } from './resolvedTimeline' 2 | import { Content } from './timeline' 3 | import { Time } from './types' 4 | 5 | export interface ResolveOptions { 6 | /** 7 | * The base time to use when resolving. 8 | * Usually you want to input the current time (Date.now()) here. 9 | */ 10 | time: Time 11 | /** 12 | * Limits the number of repeating objects in the future. 13 | * Defaults to 2, which means that the current one and the next will be resolved. 14 | */ 15 | limitCount?: number 16 | /** 17 | * Limits the repeating objects and nextEvents to a time in the future. 18 | * It is recommended to set this to a time in the future at which you plan to re-resolve the timeline again. 19 | */ 20 | limitTime?: Time 21 | /** 22 | * An object that is used to persist cache-data between resolves. 23 | * If you provide this, ensure that you provide the same object between resolves. 24 | * Setting it will increase performance, especially when there are only small changes to the timeline. 25 | */ 26 | cache?: Partial 27 | 28 | /** 29 | * Maximum depth of conflict resolution, per object. 30 | * If there are deeper relationships than this, the resolver will throw an error complaining about a circular reference. 31 | * Defaults to 5. 32 | */ 33 | conflictMaxDepth?: number 34 | 35 | /** 36 | * If set to true, will output debug information to the console 37 | */ 38 | debug?: boolean 39 | 40 | /** 41 | * If true, will store traces of the resolving into resolvedTimeline.statistics.resolveTrace. 42 | * This decreases performance slightly. 43 | */ 44 | traceResolving?: boolean 45 | 46 | /** 47 | * Skip timeline validation. 48 | * This improves performance slightly, but will not catch errors in the input timeline so use with caution. 49 | */ 50 | skipValidation?: boolean 51 | 52 | /** Skip generating statistics, this improves performance slightly. */ 53 | skipStatistics?: boolean 54 | 55 | /** Don't throw when a timeline-error (such as circular dependency) occurs. The Error will instead be written to resolvedTimeline.error */ 56 | dontThrowOnError?: boolean 57 | } 58 | export interface ResolverCache { 59 | objHashes: { [id: string]: string } 60 | 61 | objects: ResolvedTimelineObjects 62 | /** Set to true if the data in the cache can be used */ 63 | canBeUsed?: boolean 64 | } 65 | -------------------------------------------------------------------------------- /src/api/state.ts: -------------------------------------------------------------------------------- 1 | import { NextEvent, ResolvedTimelineObjectInstance } from './resolvedTimeline' 2 | import { Content } from './timeline' 3 | import { Time } from './types' 4 | 5 | /** 6 | * TimelineState is a cross-section of the timeline at a given point in time, 7 | * i.e. all objects that are active at that moment. 8 | */ 9 | export interface TimelineState { 10 | /** The timestamp for this state */ 11 | time: Time 12 | /** All objects that are active on each respective layer */ 13 | layers: StateInTime 14 | /** 15 | * A sorted list of the points in time where the next thing will happen on the timeline. 16 | * .nextEvents[0] is the next event to happen. 17 | */ 18 | nextEvents: NextEvent[] 19 | } 20 | export interface StateInTime { 21 | [layer: string]: ResolvedTimelineObjectInstance 22 | } 23 | -------------------------------------------------------------------------------- /src/api/timeline.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from './expression' 2 | import { ObjectId } from './types' 3 | 4 | export interface TimelineObject { 5 | /** ID of the object. Must be unique! */ 6 | id: ObjectId 7 | 8 | /** Expression (or array of expressions) defining when the Timeline-object will play */ 9 | enable: TimelineEnable | TimelineEnable[] 10 | 11 | /** 12 | * The layer where the object is played. 13 | * If set to undefined, "" or null, the object is treated as "transparent", 14 | * ie it won't collide with other objects, nor be present in the resolved state. 15 | * */ 16 | layer: string | number 17 | 18 | /** 19 | * Children object of the group. 20 | * If provided, also set .isGroup property to true. 21 | */ 22 | children?: TimelineObject[] 23 | isGroup?: boolean 24 | 25 | /** 26 | * Keyframes can be used to modify the content of an object. 27 | * When a keyframe is active, the content of the keyframe will be merged into the parent object. 28 | */ 29 | keyframes?: TimelineKeyframe>[] 30 | 31 | /** 32 | * A list of classes on this Timeline-object. classes can be referenced by other objects using the syntax: ".className" 33 | */ 34 | classes?: string[] 35 | 36 | /** If set to true, the object will be excluded when resolving the timeline. */ 37 | disabled?: boolean 38 | 39 | /** 40 | * Priority. Affects which object "wins" when there are two colliding objects on the same layer. 41 | * If the two colliding objects have the same priority, the one which started playing last wins. 42 | * Otherwise, the one with the highest priority wins (ie 9 wins over 0). 43 | * Defaults to 0 44 | */ 45 | priority?: number 46 | 47 | /** 48 | * If set to true, colliding timeline-instances will be merged into a single one. 49 | * This could be useful if want the instance.start times to not be reset unexpectedly. 50 | */ 51 | seamless?: boolean 52 | 53 | /** The payload of the timeline-object. Can be anything you want. */ 54 | content: TContent 55 | } 56 | export type Content = { 57 | [key: string]: any 58 | } 59 | export interface TimelineEnable { 60 | /** 61 | * Examples of Expressions: 62 | * "#objectId" 63 | * "#objectId.start" 64 | * "#objectId.end" 65 | * "#objectId.duration" 66 | * ".className" 67 | * ".className.start + 5" 68 | * "$layerName" 69 | */ 70 | 71 | /** (Optional) The start time of the object. (Cannot be combined with .while) */ 72 | start?: Expression 73 | /** (Optional) The end time of the object (Cannot be combined with .while or .duration) */ 74 | end?: Expression 75 | /** (Optional) Enables the object WHILE expression is true (ie sets both the start and end). (Cannot be combined with .start, .end or .duration ) */ 76 | while?: Expression 77 | /** (Optional) The duration of an object */ 78 | duration?: Expression 79 | /** (Optional) Makes the object repeat with given interval */ 80 | repeating?: Expression 81 | } 82 | export interface TimelineKeyframe { 83 | /** 84 | * ID of the Timeline-object. 85 | * Must be unique (timeline-objects are also considered)! 86 | */ 87 | id: ObjectId 88 | 89 | /** 90 | * Expression (or array of expressions) defining when the Timeline-object will play. 91 | * If this is an absolute value, it is counted as relative to the parent object. 92 | */ 93 | enable: TimelineEnable | TimelineEnable[] 94 | 95 | /** 96 | * A list of classes on this Timeline-object. classes can be referenced by other objects using the syntax: ".className" 97 | */ 98 | classes?: string[] 99 | 100 | /** If set to true, the object will be excluded when resolving the timeline. */ 101 | disabled?: boolean 102 | 103 | /** 104 | * The payload of the timeline-object. 105 | * This is deeply extended onto the parent object when the keyframe is active. 106 | */ 107 | content: TContent 108 | } 109 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | /** Point in time, (timestamp) */ 2 | export type Time = number 3 | /** Duration */ 4 | export type Duration = number 5 | 6 | /** Id of a timeline-object */ 7 | export type ObjectId = string 8 | 9 | export type InstanceId = `@${string}` 10 | 11 | // References: 12 | 13 | export type ObjectReference = `#${string}` 14 | export type ClassReference = `.${string}` 15 | export type LayerReference = `$${string}` 16 | export type InstanceReference = `@${InstanceId}` 17 | export type ParentReference = '##parent' 18 | export type Reference = ObjectReference | ClassReference | LayerReference | InstanceReference | ParentReference 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ResolvedTimeline, 3 | ResolveOptions, 4 | Expression, 5 | ExpressionObj, 6 | InnerExpression, 7 | Time, 8 | TimelineState, 9 | Content, 10 | TimelineKeyframe, 11 | TimelineObject, 12 | } from './api' 13 | export * from './api' 14 | export { ResolveError } from './resolver/lib/Error' 15 | 16 | import { StateHandler } from './resolver/StateHandler' 17 | import { ExpressionHandler } from './resolver/ExpressionHandler' 18 | import { ResolverHandler } from './resolver/ResolverHandler' 19 | import { TimelineValidator } from './resolver/TimelineValidator' 20 | 21 | /** 22 | * Resolves a timeline, i.e. resolves the references between objects 23 | * and calculates the absolute times for all objects in the timeline. 24 | */ 25 | export function resolveTimeline( 26 | timeline: TimelineObject[], 27 | options: ResolveOptions 28 | ): ResolvedTimeline { 29 | const resolverInstance = new ResolverHandler(options) 30 | return resolverInstance.resolveTimeline(timeline) 31 | } 32 | 33 | /** 34 | * Retrieve the state for a certain point in time. 35 | * The state contains all objects that are active at that point in time. 36 | * @param resolvedTimeline 37 | * @param time 38 | * @param eventLimit 39 | */ 40 | export function getResolvedState( 41 | resolvedTimeline: ResolvedTimeline, 42 | time: Time, 43 | eventLimit = 0 44 | ): TimelineState { 45 | const stateHandler = new StateHandler() 46 | return stateHandler.getState(resolvedTimeline, time, eventLimit) 47 | } 48 | 49 | /** 50 | * Validates all objects in the timeline. Throws an error if something's wrong 51 | * @param timeline The timeline to validate 52 | * @param strict Set to true to enable some optional strict rules. Set this to true to increase future compatibility. 53 | */ 54 | export function validateTimeline(timeline: TimelineObject[], strict?: boolean): void { 55 | const validator = new TimelineValidator() 56 | validator.validateTimeline(timeline, strict) 57 | } 58 | /** 59 | * Validates a Timeline-object. Throws an error if something's wrong 60 | * @param timeline The timeline to validate 61 | * @param strict Set to true to enable some optional strict rules. Set this to true to increase future compatibility. 62 | */ 63 | export function validateObject(obj: TimelineObject, strict?: boolean): void { 64 | const validator = new TimelineValidator() 65 | validator.validateObject(obj, strict) 66 | } 67 | 68 | /** 69 | * Validates a Timeline-keyframe. Throws an error if something's wrong 70 | * @param timeline The timeline to validate 71 | * @param strict Set to true to enable some optional strict rules. Set this to true to increase future compatibility. 72 | */ 73 | export function validateKeyframe(keyframe: TimelineKeyframe, strict?: boolean): void { 74 | const validator = new TimelineValidator() 75 | validator.validateKeyframe(keyframe, strict) 76 | } 77 | 78 | /** 79 | * Validates a string that is used in Timeline as a reference (an id, a class or layer) 80 | * @param str The string to validate 81 | * @param strict Set to true to enable some optional strict rules. Set this to true to increase future compatibility. 82 | */ 83 | export function validateReferenceString(str: string, strict?: boolean): void { 84 | TimelineValidator.validateReferenceString(str, strict) 85 | } 86 | 87 | /** 88 | * Apply keyframe content onto its parent content. 89 | * The keyframe content is deeply-applied onto the parent content. 90 | * Note: This function mutates the parentContent. 91 | */ 92 | export function applyKeyframeContent(parentContent: Content, keyframeContent: Content): void { 93 | StateHandler.applyKeyframeContent(parentContent, keyframeContent) 94 | } 95 | 96 | let expressionHandler: ExpressionHandler | undefined = undefined 97 | function getExpressionHandler(): ExpressionHandler { 98 | if (!expressionHandler) expressionHandler = new ExpressionHandler(true) 99 | return expressionHandler 100 | } 101 | export function interpretExpression(expression: null): null 102 | export function interpretExpression(expression: number): number 103 | export function interpretExpression(expression: ExpressionObj): ExpressionObj 104 | export function interpretExpression(expression: string | Expression): Expression 105 | export function interpretExpression(expression: Expression): Expression { 106 | return getExpressionHandler().interpretExpression(expression) 107 | } 108 | export function simplifyExpression(expr0: Expression): Expression { 109 | return getExpressionHandler().simplifyExpression(expr0) 110 | } 111 | export function wrapInnerExpressions(words: Array): InnerExpression { 112 | return getExpressionHandler().wrapInnerExpressions(words) 113 | } 114 | export function validateExpression(operatorList: string[], expr0: Expression, breadcrumbs?: string): true { 115 | return getExpressionHandler().validateExpression(operatorList, expr0, breadcrumbs) 116 | } 117 | 118 | /** 119 | * If you have called any of the manual expression-functions, such as interpretExpression(), 120 | * you could call this to manually clean up an internal cache, to ensure your application quits cleanly. 121 | */ 122 | export function onCloseCleanup(): void { 123 | if (expressionHandler) expressionHandler.clearCache() 124 | } 125 | -------------------------------------------------------------------------------- /src/resolver/CacheHandler.ts: -------------------------------------------------------------------------------- 1 | import { Reference, ResolvedTimelineObject, ResolvedTimelineObjects, ResolverCache } from '../api' 2 | import { ResolvedTimelineHandler } from './ResolvedTimelineHandler' 3 | import { mapToObject } from './lib/lib' 4 | import { tic } from './lib/performance' 5 | import { getRefLayer, getRefObjectId, isLayerReference, isObjectReference, joinReferences } from './lib/reference' 6 | import { objHasLayer } from './lib/timeline' 7 | 8 | export class CacheHandler { 9 | /** A Persistent store. This object contains data that is persisted between resolves. */ 10 | private cache: ResolverCache 11 | 12 | private canUseIncomingCache: boolean 13 | 14 | constructor(cache: Partial, private resolvedTimeline: ResolvedTimelineHandler) { 15 | if (!cache.objHashes) cache.objHashes = {} 16 | if (!cache.objects) cache.objects = {} 17 | 18 | if (!cache.canBeUsed) { 19 | // Reset the cache: 20 | CacheHandler.resetCache(cache) 21 | 22 | this.canUseIncomingCache = false 23 | } else { 24 | this.canUseIncomingCache = true 25 | } 26 | 27 | if (this.resolvedTimeline.traceResolving) { 28 | this.resolvedTimeline.addResolveTrace(`cache: init`) 29 | this.resolvedTimeline.addResolveTrace(`cache: canUseIncomingCache: ${this.canUseIncomingCache}`) 30 | this.resolvedTimeline.addResolveTrace( 31 | `cache: cached objects: ${JSON.stringify(Object.keys(cache.objects))}` 32 | ) 33 | } 34 | 35 | // cache.canBeUsed will be set in this.persistData() 36 | cache.canBeUsed = false 37 | 38 | this.cache = cache as ResolverCache 39 | } 40 | public determineChangedObjects(): void { 41 | const toc = tic(' cache.determineChangedObjects') 42 | // Go through all new objects, and determine whether they have changed: 43 | const allNewObjects: { [objId: string]: true } = {} 44 | 45 | const changedTracker = new ChangedTracker() 46 | 47 | for (const obj of this.resolvedTimeline.objectsMap.values()) { 48 | const oldHash = this.cache.objHashes[obj.id] 49 | const newHash = hashTimelineObject(obj) 50 | allNewObjects[obj.id] = true 51 | 52 | if (!oldHash) { 53 | if (this.resolvedTimeline.traceResolving) { 54 | this.resolvedTimeline.addResolveTrace(`cache: object "${obj.id}" is new`) 55 | } 56 | } else if (oldHash !== newHash) { 57 | if (this.resolvedTimeline.traceResolving) { 58 | this.resolvedTimeline.addResolveTrace(`cache: object "${obj.id}" has changed`) 59 | } 60 | } 61 | if ( 62 | // Object is new: 63 | !oldHash || 64 | // Object has changed: 65 | oldHash !== newHash 66 | ) { 67 | this.cache.objHashes[obj.id] = newHash 68 | changedTracker.addChangedObject(obj) 69 | 70 | const oldObj = this.cache.objects[obj.id] 71 | if (oldObj) changedTracker.addChangedObject(oldObj) 72 | } else { 73 | // No timing-affecting changes detected 74 | /* istanbul ignore if */ 75 | if (!oldHash) { 76 | if (this.resolvedTimeline.traceResolving) { 77 | this.resolvedTimeline.addResolveTrace(`cache: object "${obj.id}" is similar`) 78 | } 79 | } 80 | 81 | // Even though the timeline-properties hasn't changed, 82 | // the content (and other properties) might have: 83 | const oldObj = this.cache.objects[obj.id] 84 | 85 | /* istanbul ignore if */ 86 | if (!oldObj) { 87 | console.error(`oldHash: "${oldHash}"`) 88 | console.error(`ids: ${JSON.stringify(Object.keys(this.cache.objects))}`) 89 | throw new Error(`Internal Error: obj "${obj.id}" not found in cache, even though hashes match!`) 90 | } 91 | 92 | this.cache.objects[obj.id] = { 93 | ...obj, 94 | resolved: oldObj.resolved, 95 | } 96 | } 97 | } 98 | if (this.canUseIncomingCache) { 99 | // Go through all old hashes, removing the ones that doesn't exist anymore 100 | for (const objId in this.cache.objects) { 101 | if (!allNewObjects[objId]) { 102 | const obj = this.cache.objects[objId] 103 | delete this.cache.objHashes[objId] 104 | changedTracker.addChangedObject(obj) 105 | } 106 | } 107 | // At this point, all directly changed objects have been marked as changed. 108 | 109 | // Next step is to invalidate any indirectly affected objects, by gradually removing the invalidated ones from validObjects 110 | 111 | // Prepare the invalidator, ie populate it with the objects that are still valid: 112 | const invalidator = new Invalidator() 113 | for (const obj of this.resolvedTimeline.objectsMap.values()) { 114 | invalidator.addValidObject(obj) 115 | } 116 | 117 | for (const obj of this.resolvedTimeline.objectsMap.values()) { 118 | // Add everything that this object affects: 119 | const cachedObj = this.cache.objects[obj.id] 120 | let affectedReferences = getAllReferencesThisObjectAffects(obj) 121 | if (cachedObj) { 122 | affectedReferences = joinReferences( 123 | affectedReferences, 124 | getAllReferencesThisObjectAffects(cachedObj) 125 | ) 126 | } 127 | for (let i = 0; i < affectedReferences.length; i++) { 128 | const ref = affectedReferences[i] 129 | const objRef: Reference = `#${obj.id}` 130 | if (ref !== objRef) { 131 | invalidator.addAffectedReference(objRef, ref) 132 | } 133 | } 134 | 135 | // Add everything that this object is affected by: 136 | if (changedTracker.isChanged(`#${obj.id}`)) { 137 | // The object is directly said to have changed. 138 | } else { 139 | // The object is not directly said to have changed. 140 | // But if might have been affected by other objects that have changed. 141 | 142 | // Note: we only have to check for the OLD object, since if the old and the new object differs, 143 | // that would mean it'll be directly invalidated anyway. 144 | if (cachedObj) { 145 | // Fetch all references for the object from the last time it was resolved. 146 | // Note: This can be done, since _if_ the object was changed in any way since last resolve 147 | // it'll be invalidated anyway 148 | const dependOnReferences = cachedObj.resolved.directReferences 149 | 150 | // Build up objectLayerMap: 151 | if (objHasLayer(cachedObj)) { 152 | invalidator.addObjectOnLayer(`${cachedObj.layer}`, obj) 153 | } 154 | 155 | for (let i = 0; i < dependOnReferences.length; i++) { 156 | const ref = dependOnReferences[i] 157 | invalidator.addAffectedReference(ref, `#${obj.id}`) 158 | } 159 | } 160 | } 161 | } 162 | 163 | // Invalidate all changed objects, and recursively invalidate all objects that reference those objects: 164 | for (const reference of changedTracker.listChanged()) { 165 | invalidator.invalidateObjectsWithReference(reference) 166 | } 167 | if (this.resolvedTimeline.traceResolving) { 168 | this.resolvedTimeline.addResolveTrace( 169 | `cache: changed references: ${JSON.stringify(Array.from(changedTracker.listChanged()))}` 170 | ) 171 | this.resolvedTimeline.addResolveTrace( 172 | `cache: invalidated objects: ${JSON.stringify(Array.from(invalidator.getInValidObjectIds()))}` 173 | ) 174 | this.resolvedTimeline.addResolveTrace( 175 | `cache: unchanged objects: ${JSON.stringify(invalidator.getValidObjects().map((o) => o.id))}` 176 | ) 177 | } 178 | 179 | // At this point, the objects that are left in validObjects are still valid (ie has not changed or is affected by any others). 180 | // We can reuse the old resolving for those: 181 | for (const obj of invalidator.getValidObjects()) { 182 | if (!this.cache.objects[obj.id]) { 183 | /* istanbul ignore next */ 184 | throw new Error( 185 | `Internal Error: Something went wrong: "${obj.id}" does not exist in cache.resolvedTimeline.objects` 186 | ) 187 | } 188 | 189 | this.resolvedTimeline.objectsMap.set(obj.id, this.cache.objects[obj.id]) 190 | } 191 | } 192 | toc() 193 | } 194 | 195 | public persistData(): void { 196 | const toc = tic(' cache.persistData') 197 | 198 | if (this.resolvedTimeline.resolveError) { 199 | // If there was a resolve error, clear the cache: 200 | this.cache.objHashes = {} 201 | this.cache.objects = {} 202 | this.cache.canBeUsed = false 203 | } else { 204 | this.cache.objects = mapToObject(this.resolvedTimeline.objectsMap) 205 | this.cache.canBeUsed = true 206 | } 207 | 208 | toc() 209 | } 210 | /** Resets / Clears the cache */ 211 | static resetCache(cache: Partial): void { 212 | delete cache.canBeUsed 213 | cache.objHashes = {} 214 | cache.objects = {} 215 | } 216 | } 217 | /** Return a "hash-string" which changes whenever anything that affects timing of a timeline-object has changed. */ 218 | export function hashTimelineObject(obj: ResolvedTimelineObject): string { 219 | /* 220 | Note: The following properties are ignored, as they don't affect timing or resolving: 221 | * id 222 | * children 223 | * keyframes 224 | * isGroup 225 | * content 226 | */ 227 | return `${JSON.stringify(obj.enable)},${+!!obj.disabled},${obj.priority}',${obj.resolved.parentId},${+obj.resolved 228 | .isKeyframe},${obj.classes ? obj.classes.join('.') : ''},${obj.layer},${+!!obj.seamless}` 229 | } 230 | function getAllReferencesThisObjectAffects(newObj: ResolvedTimelineObject): Reference[] { 231 | const references: Reference[] = [`#${newObj.id}`] 232 | 233 | if (newObj.classes) { 234 | for (const className of newObj.classes) { 235 | references.push(`.${className}`) 236 | } 237 | } 238 | if (objHasLayer(newObj)) references.push(`$${newObj.layer}`) 239 | 240 | if (newObj.children) { 241 | for (const child of newObj.children) { 242 | references.push(`#${child.id}`) 243 | } 244 | } 245 | return references 246 | } 247 | /** 248 | * Keeps track of which timeline object have been changed 249 | */ 250 | class ChangedTracker { 251 | private changedReferences = new Set() 252 | 253 | /** 254 | * Mark an object as "has changed". 255 | * Will store all references that are affected by this object. 256 | */ 257 | public addChangedObject(obj: ResolvedTimelineObject) { 258 | for (const ref of getAllReferencesThisObjectAffects(obj)) { 259 | this.changedReferences.add(ref) 260 | } 261 | } 262 | /** Returns true if a reference has changed */ 263 | public isChanged(ref: Reference): boolean { 264 | return this.changedReferences.has(ref) 265 | } 266 | /** Returns a list of all changed references */ 267 | public listChanged(): IterableIterator { 268 | return this.changedReferences.keys() 269 | } 270 | } 271 | 272 | /** The Invalidator */ 273 | class Invalidator { 274 | private handledReferences: { [ref: Reference]: true } = {} 275 | /** All references that depend on another reference (ie objects, class or layers): */ 276 | private affectReferenceMap: { [ref: Reference]: Reference[] } = {} 277 | private validObjects: ResolvedTimelineObjects = {} 278 | private inValidObjectIds: string[] = [] 279 | /** Map of which objects can be affected by any other object, per layer */ 280 | private objectLayerMap: { [layer: string]: string[] } = {} 281 | 282 | public addValidObject(obj: ResolvedTimelineObject) { 283 | this.validObjects[obj.id] = obj 284 | } 285 | public getValidObjects(): ResolvedTimelineObject[] { 286 | return Object.values(this.validObjects) 287 | } 288 | public getInValidObjectIds(): string[] { 289 | return this.inValidObjectIds 290 | } 291 | public addObjectOnLayer(layer: string, obj: ResolvedTimelineObject) { 292 | if (!this.objectLayerMap[layer]) this.objectLayerMap[layer] = [] 293 | this.objectLayerMap[layer].push(obj.id) 294 | } 295 | public addAffectedReference(objRef: Reference, ref: Reference) { 296 | if (!this.affectReferenceMap[objRef]) this.affectReferenceMap[objRef] = [] 297 | this.affectReferenceMap[objRef].push(ref) 298 | } 299 | 300 | /** Invalidate all changed objects, and recursively invalidate all objects that reference those objects */ 301 | public invalidateObjectsWithReference(reference: Reference) { 302 | if (this.handledReferences[reference]) return // to avoid infinite loops 303 | this.handledReferences[reference] = true 304 | 305 | if (isObjectReference(reference)) { 306 | const objId = getRefObjectId(reference) 307 | if (this.validObjects[objId]) { 308 | delete this.validObjects[objId] 309 | this.inValidObjectIds.push(objId) 310 | } 311 | } 312 | if (isLayerReference(reference)) { 313 | const layer = getRefLayer(reference) 314 | if (this.objectLayerMap[layer]) { 315 | for (const affectedObjId of this.objectLayerMap[layer]) { 316 | this.invalidateObjectsWithReference(`#${affectedObjId}`) 317 | } 318 | } 319 | } 320 | 321 | // Invalidate all objects that depend on any of the references that this reference affects: 322 | const affectedReferences = this.affectReferenceMap[reference] 323 | if (affectedReferences) { 324 | for (let i = 0; i < affectedReferences.length; i++) { 325 | const referencingReference = affectedReferences[i] 326 | this.invalidateObjectsWithReference(referencingReference) 327 | } 328 | } 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/resolver/ExpressionHandler.ts: -------------------------------------------------------------------------------- 1 | import { ExpressionObj, Expression, InnerExpression, ExpressionOperator } from '../api/expression' 2 | import { compact, isArray, isObject } from './lib/lib' 3 | import { Cache } from './lib/cache' 4 | import { isNumericExpr } from './lib/expression' 5 | import { ResolveOptions } from '../api/resolver' 6 | 7 | export const OPERATORS: ExpressionOperator[] = ['&', '|', '+', '-', '*', '/', '%', '!'] 8 | 9 | export const REGEXP_OPERATORS = new RegExp('([' + OPERATORS.map((o) => '\\' + o).join('') + '\\(\\)])', 'g') 10 | 11 | export class ExpressionHandler { 12 | private cache: Cache 13 | 14 | constructor(autoClearCache?: boolean, private skipValidation?: ResolveOptions['skipValidation']) { 15 | this.cache = new Cache(autoClearCache) 16 | } 17 | 18 | public interpretExpression(expression: null): null 19 | public interpretExpression(expression: number): number 20 | public interpretExpression(expression: ExpressionObj): ExpressionObj 21 | public interpretExpression(expression: string | Expression): Expression 22 | public interpretExpression(expression: Expression): Expression { 23 | if (isNumericExpr(expression)) { 24 | return parseFloat(expression as string) 25 | } else if (typeof expression === 'string') { 26 | const expressionString: string = expression 27 | return this.cache.cacheResult( 28 | expressionString, 29 | () => { 30 | const expr = expressionString.replace(REGEXP_OPERATORS, ' $1 ') // Make sure there's a space between every operator & operand 31 | 32 | const words: string[] = compact(expr.split(' ')) 33 | 34 | if (words.length === 0) return null // empty expression 35 | 36 | // Fix special case: a + - b 37 | for (let i = words.length - 2; i >= 1; i--) { 38 | if ((words[i] === '-' || words[i] === '+') && wordIsOperator(OPERATORS, words[i - 1])) { 39 | words[i] = words[i] + words[i + 1] 40 | words.splice(i + 1, 1) 41 | } 42 | } 43 | 44 | const innerExpression = this.wrapInnerExpressions(words) 45 | if (innerExpression.rest.length) 46 | throw new Error(`interpretExpression: syntax error: parentheses don't add up in "${expr}".`) 47 | if (innerExpression.inner.length % 2 !== 1) { 48 | throw new Error( 49 | `interpretExpression: operands & operators don't add up: "${innerExpression.inner.join( 50 | ' ' 51 | )}".` 52 | ) 53 | } 54 | 55 | const returnExpression = this.words2Expression(OPERATORS, innerExpression.inner) 56 | if (!this.skipValidation) this.validateExpression(OPERATORS, returnExpression) 57 | return returnExpression 58 | }, 59 | 60 * 60 * 1000 // 1 hour 60 | ) 61 | } else { 62 | return expression 63 | } 64 | } 65 | /** Try to simplify an expression, this includes: 66 | * * Combine constant operands, using arithmetic operators 67 | * ...more to come? 68 | */ 69 | public simplifyExpression(expr0: Expression): Expression { 70 | const expr = typeof expr0 === 'string' ? this.interpretExpression(expr0) : expr0 71 | if (!expr) return expr 72 | 73 | if (isExpressionObject(expr)) { 74 | const l = this.simplifyExpression(expr.l) 75 | const o = expr.o 76 | const r = this.simplifyExpression(expr.r) 77 | 78 | if (typeof l === 'number' && typeof r === 'number') { 79 | // The operands can be combined: 80 | switch (o) { 81 | case '+': 82 | return l + r 83 | case '-': 84 | return l - r 85 | case '*': 86 | return l * r 87 | case '/': 88 | return l / r 89 | case '%': 90 | return l % r 91 | default: 92 | return { l, o, r } 93 | } 94 | } 95 | return { l, o, r } 96 | } 97 | return expr 98 | } 99 | 100 | // Turns ['a', '(', 'b', 'c', ')'] into ['a', ['b', 'c']] 101 | // or ['a', '&', '!', 'b'] into ['a', '&', ['', '!', 'b']] 102 | public wrapInnerExpressions(words: any[]): InnerExpression { 103 | for (let i = 0; i < words.length; i++) { 104 | switch (words[i]) { 105 | case '(': { 106 | const tmp = this.wrapInnerExpressions(words.slice(i + 1)) 107 | 108 | // insert inner expression and remove tha 109 | words[i] = tmp.inner 110 | words.splice(i + 1, 99999, ...tmp.rest) 111 | break 112 | } 113 | case ')': 114 | return { 115 | inner: words.slice(0, i), 116 | rest: words.slice(i + 1), 117 | } 118 | case '!': { 119 | const tmp = this.wrapInnerExpressions(words.slice(i + 1)) 120 | 121 | // insert inner expression after the '!' 122 | words[i] = ['', '!'].concat(tmp.inner) 123 | words.splice(i + 1, 99999, ...tmp.rest) 124 | break 125 | } 126 | } 127 | } 128 | return { 129 | inner: words, 130 | rest: [], 131 | } 132 | } 133 | 134 | /** Validates an expression. Returns true on success, throws error if not */ 135 | public validateExpression(operatorList: string[], expr0: Expression, breadcrumbs?: string): true { 136 | if (!breadcrumbs) breadcrumbs = 'ROOT' 137 | 138 | if (isObject(expr0) && !isArray(expr0)) { 139 | const expr: ExpressionObj = expr0 140 | 141 | if (expr.l === undefined) 142 | throw new Error(`validateExpression: ${breadcrumbs}.l missing in ${JSON.stringify(expr)}`) 143 | if (expr.o === undefined) 144 | throw new Error(`validateExpression: ${breadcrumbs}.o missing in ${JSON.stringify(expr)}`) 145 | if (expr.r === undefined) 146 | throw new Error(`validateExpression: ${breadcrumbs}.r missing in ${JSON.stringify(expr)}`) 147 | 148 | if (typeof expr.o !== 'string') throw new Error(`validateExpression: ${breadcrumbs}.o not a string`) 149 | 150 | if (!wordIsOperator(operatorList, expr.o)) throw new Error(breadcrumbs + '.o not valid: "' + expr.o + '"') 151 | 152 | return ( 153 | this.validateExpression(operatorList, expr.l, breadcrumbs + '.l') && 154 | this.validateExpression(operatorList, expr.r, breadcrumbs + '.r') 155 | ) 156 | } else if (expr0 !== null && typeof expr0 !== 'string' && typeof expr0 !== 'number') { 157 | throw new Error(`validateExpression: ${breadcrumbs} is of invalid type`) 158 | } 159 | return true 160 | } 161 | 162 | public clearCache(): void { 163 | this.cache.clear() 164 | } 165 | private words2Expression(operatorList: string[], words: any[]): Expression { 166 | /* istanbul ignore if */ 167 | if (!words?.length) throw new Error('words2Expression: syntax error: unbalanced expression') 168 | while (words.length === 1 && words[0] !== null && isArray(words[0])) words = words[0] 169 | if (words.length === 1) return words[0] 170 | 171 | // Find the operator with the highest priority: 172 | let operatorI = -1 173 | for (let i = 0; i < operatorList.length; i++) { 174 | const operator = operatorList[i] 175 | 176 | if (operatorI === -1) { 177 | operatorI = words.lastIndexOf(operator) 178 | } 179 | } 180 | 181 | if (operatorI !== -1) { 182 | const l = words.slice(0, operatorI) 183 | const r = words.slice(operatorI + 1) 184 | const expr: ExpressionObj = { 185 | l: this.words2Expression(operatorList, l), 186 | o: words[operatorI], 187 | r: this.words2Expression(operatorList, r), 188 | } 189 | 190 | return expr 191 | } else throw new Error('words2Expression: syntax error: operator not found: "' + words.join(' ') + '"') 192 | } 193 | } 194 | 195 | function isExpressionObject(expr: Expression): expr is ExpressionObj { 196 | return ( 197 | typeof expr === 'object' && 198 | expr !== null && 199 | expr.l !== undefined && 200 | expr.o !== undefined && 201 | expr.r !== undefined 202 | ) 203 | } 204 | function wordIsOperator(operatorList: string[], word: string) { 205 | if (operatorList.indexOf(word) !== -1) return true 206 | return false 207 | } 208 | -------------------------------------------------------------------------------- /src/resolver/LayerStateHandler.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedTimelineObject, ResolvedTimelineObjectInstance, TimelineObjectInstance } from '../api' 2 | import { InstanceHandler } from './InstanceHandler' 3 | import { ResolvedTimelineHandler } from './ResolvedTimelineHandler' 4 | import { compareStrings } from './lib/lib' 5 | import { tic } from './lib/performance' 6 | 7 | /** 8 | * LayerStateHandler instances are short-lived. 9 | * They are initialized, .resolveConflicts() is called and then discarded 10 | */ 11 | export class LayerStateHandler { 12 | private pointsInTime: { 13 | [time: string]: InstanceAtPointInTime[] 14 | } = {} 15 | 16 | /** List of object ids on the layer */ 17 | public objectIdsOnLayer: string[] 18 | /** List of objects on the layer. (this is populated in .resolveConflicts() ) */ 19 | public objectsOnLayer: ResolvedTimelineObject[] 20 | 21 | constructor( 22 | private resolvedTimeline: ResolvedTimelineHandler, 23 | private instance: InstanceHandler, 24 | private layer: string, 25 | /** 26 | * Maps an array of object ids to an object id (objects that directly reference an reference). 27 | */ 28 | private directReferenceMap: Map 29 | ) { 30 | this.objectsOnLayer = [] 31 | this.objectIdsOnLayer = this.resolvedTimeline.getLayerObjects(layer) 32 | } 33 | 34 | /** Resolve conflicts between objects on the layer. */ 35 | public resolveConflicts(): void { 36 | const toc = tic(' resolveConflicts') 37 | 38 | /* 39 | This algorithm basically works like this: 40 | 41 | 1. Collect all instances start- and end-times as points-of-interest 42 | 2. Sweep through the points-of-interest and determine which instance is the "winning one" at every point in time 43 | */ 44 | 45 | // Populate this.objectsOnLayer: 46 | for (const objId of this.objectIdsOnLayer) { 47 | this.objectsOnLayer.push(this.resolvedTimeline.getObject(objId)) 48 | } 49 | if (this.resolvedTimeline.traceResolving) 50 | this.resolvedTimeline.addResolveTrace( 51 | `LayerState: Resolve conflicts for layer "${this.layer}", objects: ${this.objectsOnLayer 52 | .map((o) => o.id) 53 | .join(', ')}` 54 | ) 55 | 56 | // Fast-path: if there's only one object on the layer, it can't conflict with anything 57 | if (this.objectsOnLayer.length === 1) { 58 | for (const obj of this.objectsOnLayer) { 59 | obj.resolved.resolvedConflicts = true 60 | 61 | for (const instance of obj.resolved.instances) { 62 | instance.originalStart = instance.originalStart ?? instance.start 63 | instance.originalEnd = instance.originalEnd ?? instance.end 64 | } 65 | } 66 | return 67 | } 68 | 69 | // Sort to make sure parent groups are evaluated before their children: 70 | this.objectsOnLayer.sort(compareObjectsOnLayer) 71 | 72 | // Step 1: Collect all points-of-interest (which points in time we want to evaluate) 73 | // and which instances that are interesting 74 | for (const obj of this.objectsOnLayer) { 75 | // Notes: 76 | // Since keyframes can't be placed on a layer, we assume that the object is not a keyframe 77 | // We also assume that the object has a layer 78 | 79 | for (const instance of obj.resolved.instances) { 80 | const timeEvents: TimeEvent[] = [] 81 | 82 | timeEvents.push({ time: instance.start, enable: true }) 83 | if (instance.end) timeEvents.push({ time: instance.end, enable: false }) 84 | 85 | // Save a reference to this instance on all points in time that could affect it: 86 | for (const timeEvent of timeEvents) { 87 | if (timeEvent.enable) { 88 | this.addPointInTime(timeEvent.time, 'start', obj, instance) 89 | } else { 90 | this.addPointInTime(timeEvent.time, 'end', obj, instance) 91 | } 92 | } 93 | } 94 | 95 | obj.resolved.resolvedConflicts = true 96 | obj.resolved.instances.splice(0) // clear the instances, so new instances can be re-added later 97 | } 98 | 99 | // Step 2: Resolve the state for the points-of-interest 100 | // This is done by sweeping the points-of-interest chronologically, 101 | // determining the state for every point in time by adding & removing objects from aspiringInstances 102 | // Then sorting it to determine who takes precedence 103 | 104 | let currentState: ResolvedTimelineObjectInstance | undefined = undefined 105 | const activeObjIds: { [id: string]: ResolvedTimelineObjectInstance } = {} 106 | 107 | /** The objects in aspiringInstances */ 108 | let aspiringInstances: AspiringInstance[] = [] 109 | 110 | const times: number[] = Object.keys(this.pointsInTime) 111 | .map((time) => parseFloat(time)) 112 | // Sort chronologically: 113 | .sort((a, b) => a - b) 114 | 115 | // Iterate through all points-of-interest times: 116 | for (const time of times) { 117 | const traceConflicts: string[] = [] 118 | 119 | /** A set of identifiers for which instance-events have been check at this point in time. Used to avoid looking at the same object twice. */ 120 | const checkedThisTime = new Set() 121 | 122 | /** List of the instances to check at this point in time. */ 123 | const instancesToCheck: InstanceAtPointInTime[] = this.pointsInTime[time] 124 | instancesToCheck.sort(this.compareInstancesToCheck) 125 | 126 | for (let j = 0; j < instancesToCheck.length; j++) { 127 | const o = instancesToCheck[j] 128 | const obj: ResolvedTimelineObject = o.obj 129 | const instance: TimelineObjectInstance = o.instance 130 | 131 | let toBeEnabled: boolean 132 | if (instance.start === time && instance.end === time) { 133 | // Handle zero-length instances: 134 | if (o.instanceEvent === 'start') toBeEnabled = true // Start a zero-length instance 135 | else toBeEnabled = false // End a zero-length instance 136 | } else { 137 | toBeEnabled = (instance.start || 0) <= time && (instance.end ?? Infinity) > time 138 | } 139 | 140 | const identifier = `${obj.id}_${instance.id}_${o.instanceEvent}` 141 | if (!checkedThisTime.has(identifier)) { 142 | // Only check each object and event-type once for every point in time 143 | checkedThisTime.add(identifier) 144 | 145 | if (toBeEnabled) { 146 | // The instance wants to be enabled (is starting) 147 | 148 | // Add to aspiringInstances: 149 | aspiringInstances.push({ obj, instance }) 150 | } else { 151 | // The instance doesn't want to be enabled (is ending) 152 | 153 | // Remove from aspiringInstances: 154 | aspiringInstances = removeFromAspiringInstances(aspiringInstances, obj.id) 155 | } 156 | 157 | // Sort the instances on layer to determine who is the active one: 158 | aspiringInstances.sort(compareAspiringInstances) 159 | 160 | // At this point, the first instance in aspiringInstances is the active one. 161 | const instanceOnTopOfLayer = aspiringInstances[0] 162 | 163 | // Update current state: 164 | const prevObjInstance: ResolvedTimelineObjectInstance | undefined = currentState 165 | const replaceOld: boolean = 166 | instanceOnTopOfLayer && 167 | (!prevObjInstance || 168 | prevObjInstance.id !== instanceOnTopOfLayer.obj.id || 169 | !prevObjInstance.instance.id.startsWith(`${instanceOnTopOfLayer.instance.id}`)) 170 | const removeOld: boolean = !instanceOnTopOfLayer && prevObjInstance 171 | 172 | if (replaceOld || removeOld) { 173 | if (prevObjInstance) { 174 | // Cap the old instance, so it'll end at this point in time: 175 | this.instance.setInstanceEndTime(prevObjInstance.instance, time) 176 | 177 | // Update activeObjIds: 178 | delete activeObjIds[prevObjInstance.id] 179 | 180 | if (this.resolvedTimeline.traceResolving) traceConflicts.push(`${prevObjInstance.id} stop`) 181 | } 182 | } 183 | 184 | if (replaceOld) { 185 | // Set the new objectInstance to be the current one: 186 | 187 | const currentObj = instanceOnTopOfLayer.obj 188 | 189 | const newInstance: TimelineObjectInstance = { 190 | ...instanceOnTopOfLayer.instance, 191 | // We're setting new start & end times so they match up with the state: 192 | start: time, 193 | end: null, 194 | fromInstanceId: instanceOnTopOfLayer.instance.id, 195 | 196 | originalEnd: instanceOnTopOfLayer.instance.originalEnd ?? instanceOnTopOfLayer.instance.end, 197 | originalStart: 198 | instanceOnTopOfLayer.instance.originalStart ?? instanceOnTopOfLayer.instance.start, 199 | } 200 | // Make the instance id unique: 201 | for (let i = 0; i < currentObj.resolved.instances.length; i++) { 202 | if (currentObj.resolved.instances[i].id === newInstance.id) { 203 | newInstance.id = `${newInstance.id}_$${currentObj.resolved.instances.length}` 204 | } 205 | } 206 | currentObj.resolved.instances.push(newInstance) 207 | 208 | const newObjInstance = { 209 | ...currentObj, 210 | instance: newInstance, 211 | } 212 | 213 | // Save to current state: 214 | currentState = newObjInstance 215 | 216 | // Update activeObjIds: 217 | activeObjIds[newObjInstance.id] = newObjInstance 218 | 219 | if (this.resolvedTimeline.traceResolving) traceConflicts.push(`${newObjInstance.id} start`) 220 | } else if (removeOld) { 221 | // Remove from current state: 222 | currentState = undefined 223 | 224 | if (this.resolvedTimeline.traceResolving) traceConflicts.push(`-nothing-`) 225 | } 226 | } 227 | } 228 | if (this.resolvedTimeline.traceResolving) 229 | this.resolvedTimeline.addResolveTrace( 230 | `LayerState: Layer "${this.layer}": time: ${time}: ${traceConflicts.join(', ')}` 231 | ) 232 | } 233 | // At this point, the instances of all objects are calculated, 234 | // taking into account priorities, clashes etc. 235 | 236 | // Cap children inside their parents: 237 | // Functionally, this isn't needed since this is done in ResolvedTimelineHandler.resolveTimelineObj() anyway. 238 | // However by capping children here some re-evaluating iterations can be avoided, so this increases performance. 239 | { 240 | const allChildren = this.objectsOnLayer 241 | .filter((obj) => !!obj.resolved.parentId) 242 | // Sort, so that the outermost are handled first: 243 | .sort((a, b) => { 244 | return a.resolved.levelDeep - b.resolved.levelDeep 245 | }) 246 | 247 | for (const obj of allChildren) { 248 | if (obj.resolved.parentId) { 249 | const parent = this.resolvedTimeline.getObject(obj.resolved.parentId) 250 | if (parent) { 251 | obj.resolved.instances = this.instance.cleanInstances( 252 | this.instance.capInstances(obj.resolved.instances, parent.resolved.instances), 253 | false, 254 | false 255 | ) 256 | } 257 | } 258 | } 259 | } 260 | 261 | toc() 262 | } 263 | /** Add an instance and event to a certain point-in-time */ 264 | private addPointInTime( 265 | time: number, 266 | instanceEvent: 'start' | 'end', 267 | obj: ResolvedTimelineObject, 268 | instance: TimelineObjectInstance 269 | ) { 270 | // Note on order: Ending events come before starting events 271 | 272 | if (!this.pointsInTime[time + '']) this.pointsInTime[time + ''] = [] 273 | this.pointsInTime[time + ''].push({ obj, instance, instanceEvent }) 274 | } 275 | private compareInstancesToCheck = (a: InstanceAtPointInTime, b: InstanceAtPointInTime) => { 276 | // Note: we assume that there are no keyframes here. (if there where, they would be sorted first) 277 | 278 | if ( 279 | a.instance.id === b.instance.id && 280 | a.instance.start === b.instance.start && 281 | a.instance.end === b.instance.end 282 | ) { 283 | // A & B are the same instance, it is a zero-length instance! 284 | // In this case, put the start before the end: 285 | if (a.instanceEvent === 'start' && b.instanceEvent === 'end') return -1 286 | if (a.instanceEvent === 'end' && b.instanceEvent === 'start') return 1 287 | } 288 | 289 | // Handle ending instances first: 290 | if (a.instanceEvent === 'start' && b.instanceEvent === 'end') return 1 291 | if (a.instanceEvent === 'end' && b.instanceEvent === 'start') return -1 292 | 293 | if (a.instance.start === a.instance.end || b.instance.start === b.instance.end) { 294 | // Put later-ending instances last (in the case of zero-length vs non-zero-length instance): 295 | const difference = (a.instance.end ?? Infinity) - (b.instance.end ?? Infinity) 296 | if (difference) return difference 297 | } 298 | 299 | // If A references B, A should be handled after B, (B might resolve into a zero-length instance) 300 | const aRefObjIds = this.directReferenceMap.get(a.obj.id) 301 | if (aRefObjIds?.includes(b.obj.id)) return -1 302 | const bRefObjIds = this.directReferenceMap.get(b.obj.id) 303 | if (bRefObjIds?.includes(a.obj.id)) return 1 304 | 305 | if (a.obj.resolved && b.obj.resolved) { 306 | // Deeper objects (children in groups) comes later, we want to check the parent groups first: 307 | const difference = a.obj.resolved.levelDeep - b.obj.resolved.levelDeep 308 | if (difference) return difference 309 | } 310 | 311 | // Last resort, sort by id to make it deterministic: 312 | return compareStrings(a.obj.id, b.obj.id) || compareStrings(a.instance.id, b.instance.id) 313 | } 314 | } 315 | export interface TimeEvent { 316 | time: number 317 | /** true when the event indicate that something starts, false when something ends */ 318 | enable: boolean 319 | } 320 | 321 | interface InstanceAtPointInTime { 322 | obj: ResolvedTimelineObject 323 | instance: TimelineObjectInstance 324 | 325 | /** The same instanceEvent is only going to be checked once per timestamp */ 326 | instanceEvent: 'start' | 'end' 327 | } 328 | interface AspiringInstance { 329 | obj: ResolvedTimelineObject 330 | instance: TimelineObjectInstance 331 | } 332 | 333 | function compareObjectsOnLayer(a: ResolvedTimelineObject, b: ResolvedTimelineObject) { 334 | // Sort to make sure parent groups are evaluated before their children: 335 | return a.resolved.levelDeep - b.resolved.levelDeep || compareStrings(a.id, b.id) 336 | } 337 | 338 | const removeFromAspiringInstances = (aspiringInstances: AspiringInstance[], objId: string): AspiringInstance[] => { 339 | const returnInstances: AspiringInstance[] = [] 340 | for (let i = 0; i < aspiringInstances.length; i++) { 341 | if (aspiringInstances[i].obj.id !== objId) returnInstances.push(aspiringInstances[i]) 342 | } 343 | return returnInstances 344 | } 345 | 346 | function compareAspiringInstances(a: AspiringInstance, b: AspiringInstance) { 347 | // Determine who takes precedence: 348 | return ( 349 | (b.obj.priority || 0) - (a.obj.priority || 0) || // First, sort using priority 350 | b.instance.start - a.instance.start || // Then, sort using the start time 351 | compareStrings(a.obj.id, b.obj.id) || // Last resort, sort by id to make it deterministic 352 | compareStrings(a.instance.id, b.instance.id) 353 | ) 354 | } 355 | -------------------------------------------------------------------------------- /src/resolver/ResolverHandler.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedTimelineHandler } from './ResolvedTimelineHandler' 2 | import { ResolvedTimeline } from '../api/resolvedTimeline' 3 | import { ResolveOptions } from '../api/resolver' 4 | import { Content, TimelineObject } from '../api/timeline' 5 | import { tic } from './lib/performance' 6 | import { CacheHandler } from './CacheHandler' 7 | import { TimelineValidator } from './TimelineValidator' 8 | import { ResolveError } from './lib/Error' 9 | 10 | /** 11 | * Note: A Resolver instance is short-lived and used to resolve a timeline. 12 | * Intended usage: 13 | * 1. const resolver = new Resolver(options) 14 | * 2. resolver.run(timeline) 15 | */ 16 | export class ResolverHandler { 17 | private hasRun = false 18 | 19 | private resolvedTimeline: ResolvedTimelineHandler 20 | 21 | private validator: TimelineValidator 22 | 23 | constructor(private options: ResolveOptions) { 24 | const toc = tic('new Resolver') 25 | this.resolvedTimeline = new ResolvedTimelineHandler(this.options) 26 | this.validator = new TimelineValidator() 27 | if (this.options.traceResolving) { 28 | this.resolvedTimeline.addResolveTrace(`init`) 29 | } 30 | toc() 31 | } 32 | /** 33 | * Resolves a timeline, i.e. resolves the references between objects 34 | * This method can only be run once per Resolver instance. 35 | */ 36 | public resolveTimeline(timeline: TimelineObject[]): ResolvedTimeline { 37 | try { 38 | const toc = tic('resolveTimeline') 39 | /* istanbul ignore if */ 40 | if (this.hasRun) { 41 | if (this.options.traceResolving) this.resolvedTimeline.addResolveTrace(`Error: has already run`) 42 | throw new Error( 43 | `Resolver.resolveTimeline can only run once per instance! 44 | Usage: 45 | const resolver = new Resolver(options); 46 | resolver.run(timeline);` 47 | ) 48 | } 49 | this.hasRun = true 50 | 51 | if (this.options.traceResolving) { 52 | this.resolvedTimeline.addResolveTrace(`resolveTimeline start`) 53 | this.resolvedTimeline.addResolveTrace(`timeline object count ${timeline.length}`) 54 | } 55 | 56 | // Step 0: Validate the timeline: 57 | if (!this.options.skipValidation) { 58 | this.validator.validateTimeline(timeline, false) 59 | } 60 | 61 | // Step 1: Populate ResolvedTimeline with the timeline: 62 | for (const obj of timeline) { 63 | this.resolvedTimeline.addTimelineObject(obj) 64 | } 65 | if (this.options.traceResolving) { 66 | this.resolvedTimeline.addResolveTrace(`objects: ${this.resolvedTimeline.objectsMap.size}`) 67 | } 68 | 69 | // Step 2: Use cache: 70 | let cacheHandler: CacheHandler | undefined 71 | if (this.options.cache) { 72 | if (this.options.traceResolving) this.resolvedTimeline.addResolveTrace(`using cache`) 73 | 74 | cacheHandler = this.resolvedTimeline.initializeCache(this.options.cache) 75 | 76 | cacheHandler.determineChangedObjects() 77 | } 78 | 79 | // Step 3: Go through and resolve all objects: 80 | this.resolvedTimeline.resolveAllTimelineObjs() 81 | 82 | // Step 5: persist cache 83 | if (cacheHandler) { 84 | cacheHandler.persistData() 85 | } 86 | 87 | if (this.options.traceResolving) this.resolvedTimeline.addResolveTrace(`resolveTimeline done!`) 88 | 89 | const resolvedTimeline: ResolvedTimeline = this.resolvedTimeline.getResolvedTimeline() 90 | 91 | toc() 92 | return resolvedTimeline 93 | } catch (e) { 94 | if (this.options.cache) { 95 | // Reset cache, since it might be corrupt. 96 | CacheHandler.resetCache(this.options.cache) 97 | } 98 | 99 | throw new ResolveError(e, this.resolvedTimeline.getResolvedTimeline()) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/resolver/StateHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Content, 3 | ResolvedTimeline, 4 | ResolvedTimelineObject, 5 | ResolvedTimelineObjectInstance, 6 | Time, 7 | TimelineState, 8 | } from '../api' 9 | import { instanceIsActive } from './lib/instance' 10 | import { clone, isArray, isObject } from './lib/lib' 11 | import { tic } from './lib/performance' 12 | import { objHasLayer } from './lib/timeline' 13 | 14 | export class StateHandler { 15 | public getState( 16 | resolvedTimeline: ResolvedTimeline, 17 | time: Time, 18 | eventLimit = 0 19 | ): TimelineState { 20 | const toc = tic('getState') 21 | const state: TimelineState = { 22 | time: time, 23 | layers: {}, 24 | nextEvents: resolvedTimeline.nextEvents.filter((e) => e.time > time), 25 | } 26 | 27 | if (eventLimit) state.nextEvents = state.nextEvents.slice(0, eventLimit) 28 | 29 | for (const obj of Object.values>(resolvedTimeline.objects)) { 30 | if (!objHasLayer(obj)) continue 31 | // Note: We can assume that it is not a keyframe here, because keyframes don't have layers 32 | 33 | for (const instance of obj.resolved.instances) { 34 | if (instanceIsActive(instance, time)) { 35 | let contentIsOriginal = true 36 | const objInstance: ResolvedTimelineObjectInstance = { 37 | ...obj, 38 | instance, 39 | } 40 | /* istanbul ignore if */ 41 | if (state.layers[`${obj.layer}`]) { 42 | // There is already an object on this layer! 43 | console.error(`layer "${obj.layer}": ${JSON.stringify(state.layers[`${obj.layer}`])}`) 44 | console.error(`object "${objInstance.id}": ${JSON.stringify(objInstance)}`) 45 | throw new Error(`Internal Error: There is already an object on layer "${obj.layer}"!`) 46 | } 47 | 48 | state.layers[`${obj.layer}`] = objInstance 49 | 50 | // Now, apply keyframes: 51 | const objectKeyframes: ResolvedTimelineObject[] = obj.keyframes 52 | ? obj.keyframes.map((kf) => resolvedTimeline.objects[kf.id]) 53 | : [] 54 | 55 | for (const keyframe of this.getActiveKeyframeInstances(objectKeyframes, time)) { 56 | if (contentIsOriginal) { 57 | // We don't want to modify the original content, so we deep-clone it before modifying it: 58 | objInstance.content = clone(obj.content) 59 | contentIsOriginal = false 60 | } 61 | StateHandler.applyKeyframeContent(objInstance.content, keyframe.content) 62 | } 63 | } 64 | } 65 | } 66 | toc() 67 | return state 68 | } 69 | 70 | /** 71 | * Apply keyframe content onto its parent content. 72 | * The keyframe content is deeply-applied onto the parent content. 73 | */ 74 | public static applyKeyframeContent(parentContent: Content, keyframeContent: Content): void { 75 | const toc = tic(' applyKeyframeContent') 76 | for (const [attr, value] of Object.entries(keyframeContent)) { 77 | if (isObject(value)) { 78 | if (isArray(value)) { 79 | // Value is an array 80 | if (!Array.isArray(parentContent[attr])) parentContent[attr] = [] 81 | this.applyKeyframeContent(parentContent[attr], value) 82 | parentContent[attr].splice(value.length, Infinity) 83 | } else { 84 | // Value is an object 85 | if (!isObject(parentContent[attr]) || Array.isArray(parentContent[attr])) parentContent[attr] = {} 86 | this.applyKeyframeContent(parentContent[attr], value) 87 | } 88 | } else { 89 | parentContent[attr] = value 90 | } 91 | } 92 | toc() 93 | } 94 | private getActiveKeyframeInstances( 95 | keyframes: ResolvedTimelineObject[], 96 | time: Time 97 | ): ResolvedTimelineObjectInstance[] { 98 | const keyframeInstances: ResolvedTimelineObjectInstance[] = [] 99 | for (const keyframe of keyframes) { 100 | for (const instance of keyframe.resolved.instances) { 101 | if (instanceIsActive(instance, time)) { 102 | keyframeInstances.push({ 103 | ...keyframe, 104 | instance, 105 | }) 106 | } 107 | } 108 | } 109 | keyframeInstances.sort((a, b) => { 110 | // Highest priority is applied last: 111 | const aPriority = a.priority ?? 0 112 | const bPriority = b.priority ?? 0 113 | if (aPriority < bPriority) return -1 114 | if (aPriority > bPriority) return 1 115 | 116 | // Last start time is applied last: 117 | if (a.instance.start < b.instance.start) return -1 118 | if (a.instance.start > b.instance.start) return 1 119 | 120 | /* istanbul ignore next */ 121 | return 0 122 | }) 123 | return keyframeInstances 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/resolver/TimelineValidator.ts: -------------------------------------------------------------------------------- 1 | import { TimelineEnable, TimelineKeyframe, TimelineObject } from '../api/timeline' 2 | import { REGEXP_OPERATORS } from './ExpressionHandler' 3 | import { ensureArray } from './lib/lib' 4 | import { tic } from './lib/performance' 5 | 6 | /** These characters are reserved and cannot be used in ids, etc */ 7 | const RESERVED_CHARACTERS = /[#.$]/g 8 | 9 | /** These characters are reserved for possible future use and cannot be used in ids, etc */ 10 | const FUTURE_RESERVED_CHARACTERS = /[=?@{}[\]^§]/g 11 | 12 | /** 13 | * Note: A TimelineValidator instance is short-lived and used to validate a timeline. 14 | * Intended usage: 15 | * 1. const validator = new TimelineValidator() 16 | * 2. validator.validateTimeline(timeline) 17 | * or: 18 | * 1. const validator = new TimelineValidator() 19 | * 2. validator.validateObject(obj) 20 | * or: 21 | * 1. const validator = new TimelineValidator() 22 | * 2. validator.validateKeyframe(obj) 23 | */ 24 | export class TimelineValidator { 25 | private uniqueIds: { [id: string]: true } = {} 26 | 27 | /** Validates all objects in the timeline. Throws an error if something's wrong. */ 28 | public validateTimeline( 29 | /** The timeline to validate */ 30 | timeline: TimelineObject[], 31 | /** Set to true to enable some optional strict rules. Set this to true to increase future compatibility. */ 32 | strict?: boolean 33 | ): void { 34 | const toc = tic(' validateTimeline') 35 | for (let i = 0; i < timeline.length; i++) { 36 | const obj = timeline[i] 37 | this.validateObject(obj, strict) 38 | } 39 | toc() 40 | } 41 | 42 | /** Validates a simgle Timeline-object. Throws an error if something's wrong. */ 43 | public validateObject( 44 | /** The object to validate */ 45 | obj: TimelineObject, 46 | /** Set to true to enable some optional strict rules. Set this to true to increase future compatibility. */ 47 | strict?: boolean 48 | ): void { 49 | if (!obj) throw new Error(`Object is undefined`) 50 | if (typeof obj !== 'object') throw new Error(`Object is not an object`) 51 | 52 | try { 53 | this.validateId(obj, strict) 54 | this.validateLayer(obj, strict) 55 | this.validateContent(obj) 56 | this.validateEnable(obj, strict) 57 | 58 | if (obj.keyframes) { 59 | for (let i = 0; i < obj.keyframes.length; i++) { 60 | const keyframe = obj.keyframes[i] 61 | try { 62 | this.validateKeyframe(keyframe, strict) 63 | } catch (e) { 64 | throw new Error(`Keyframe[${i}]: ${e}`) 65 | } 66 | } 67 | } 68 | this.validateClasses(obj, strict) 69 | 70 | if (obj.children && !obj.isGroup) throw new Error(`Attribute "children" is set but "isGroup" is not`) 71 | if (obj.isGroup && !obj.children) throw new Error(`Attribute "isGroup" is set but "children" missing`) 72 | 73 | if (obj.children) { 74 | for (let i = 0; i < obj.children.length; i++) { 75 | const child = obj.children[i] 76 | try { 77 | this.validateObject(child, strict) 78 | } catch (e) { 79 | throw new Error(`Child[${i}]: ${e}`) 80 | } 81 | } 82 | } 83 | if (obj.priority !== undefined && typeof obj.priority !== 'number') 84 | throw new Error(`Attribute "priority" is not a number`) 85 | } catch (err) { 86 | if (err instanceof Error) { 87 | const err2 = new Error(`Object "${obj.id}": ${err.message}`) 88 | err2.stack = err.stack 89 | throw err2 90 | } else throw err 91 | } 92 | } 93 | /** Validates a simgle Timeline-object. Throws an error if something's wrong. */ 94 | public validateKeyframe( 95 | /** The object to validate */ 96 | keyframe: TimelineKeyframe, 97 | /** Set to true to enable some optional strict rules. Set this to true to increase future compatibility */ 98 | strict?: boolean 99 | ): void { 100 | if (!keyframe) throw new Error(`Keyframe is undefined`) 101 | if (typeof keyframe !== 'object') throw new Error(`Keyframe is not an object`) 102 | 103 | try { 104 | this.validateId(keyframe, strict) 105 | this.validateContent(keyframe) 106 | this.validateEnable(keyframe, strict) 107 | this.validateClasses(keyframe, strict) 108 | } catch (err) { 109 | if (err instanceof Error) { 110 | const err2 = new Error(`Keyframe "${keyframe.id}": ${err.message}`) 111 | err2.stack = err.stack 112 | throw err 113 | } else throw err 114 | } 115 | } 116 | private validateId(obj: TimelineObject | TimelineKeyframe, strict: boolean | undefined): void { 117 | if (!obj.id) throw new Error(`Object missing "id" attribute`) 118 | if (typeof obj.id !== 'string') throw new Error(`Object "id" attribute is not a string: "${obj.id}"`) 119 | try { 120 | TimelineValidator.validateReferenceString(obj.id, strict) 121 | } catch (err) { 122 | throw new Error(`Object "id" attribute: ${err}`) 123 | } 124 | if (this.uniqueIds[obj.id]) throw new Error(`id "${obj.id}" is not unique`) 125 | this.uniqueIds[obj.id] = true 126 | } 127 | private validateLayer(obj: TimelineObject, strict: boolean | undefined): void { 128 | if (obj.layer === undefined) 129 | throw new Error( 130 | `"layer" attribute is undefined. (If an object is to have no layer, set this to an empty string.)` 131 | ) 132 | try { 133 | TimelineValidator.validateReferenceString(`${obj.layer}`, strict) 134 | } catch (err) { 135 | throw new Error(`"layer" attribute: ${err}`) 136 | } 137 | } 138 | private validateContent(obj: TimelineObject | TimelineKeyframe): void { 139 | if (!obj.content) throw new Error(`"content" attribute must be set`) 140 | } 141 | private validateEnable(obj: TimelineObject | TimelineKeyframe, strict: boolean | undefined): void { 142 | if (!obj.enable) throw new Error(`"enable" attribute must be set`) 143 | 144 | const enables: TimelineEnable[] = ensureArray(obj.enable) 145 | for (let i = 0; i < enables.length; i++) { 146 | const enable = enables[i] 147 | 148 | if (enable.start !== undefined) { 149 | if (strict && enable.while !== undefined) 150 | throw new Error(`"enable.start" and "enable.while" cannot be combined`) 151 | 152 | if (strict && enable.end !== undefined && enable.duration !== undefined) 153 | throw new Error(`"enable.end" and "enable.duration" cannot be combined`) 154 | } else if (enable.while !== undefined) { 155 | if (strict && enable.end !== undefined) 156 | throw new Error(`"enable.while" and "enable.end" cannot be combined`) 157 | if (strict && enable.duration !== undefined) 158 | throw new Error(`"enable.while" and "enable.duration" cannot be combined`) 159 | } else throw new Error(`"enable.start" or "enable.while" must be set`) 160 | } 161 | } 162 | 163 | private validateClasses(obj: TimelineObject | TimelineKeyframe, strict: boolean | undefined): void { 164 | if (obj.classes) { 165 | for (let i = 0; i < obj.classes.length; i++) { 166 | const className = obj.classes[i] 167 | if (className && typeof className !== 'string') throw new Error(`"classes[${i}]" is not a string`) 168 | 169 | try { 170 | TimelineValidator.validateReferenceString(className, strict) 171 | } catch (err) { 172 | throw new Error(` "classes[${i}]": ${err}`) 173 | } 174 | } 175 | } 176 | } 177 | /** 178 | * Validates a string that is used in Timeline as a reference (an id, a class or layer) 179 | * @param str The string to validate 180 | * @param strict Set to true to enable some strict rules (rules that can possibly be ignored) 181 | */ 182 | static validateReferenceString(str: string, strict?: boolean): void { 183 | if (!str) return 184 | 185 | const matchesOperators = REGEXP_OPERATORS.test(str) 186 | const matchesReserved = RESERVED_CHARACTERS.test(str) 187 | const matchesFutureReserved = strict && FUTURE_RESERVED_CHARACTERS.test(str) 188 | 189 | if (matchesOperators || matchesReserved || matchesFutureReserved) { 190 | const matchOperators: string[] = str.match(REGEXP_OPERATORS) ?? [] 191 | const matchReserved: string[] = str.match(RESERVED_CHARACTERS) ?? [] 192 | const matchFutureReserved: string[] = (strict && str.match(FUTURE_RESERVED_CHARACTERS)) || [] 193 | throw new Error( 194 | `The string "${str}" contains characters which aren't allowed in Timeline: ${[ 195 | matchOperators.length > 0 && `${matchOperators.map((o) => `"${o}"`).join(', ')} (is an operator)`, 196 | matchReserved.length > 0 && 197 | `${matchReserved.map((o) => `"${o}"`).join(', ')} (is a reserved character)`, 198 | matchFutureReserved.length > 0 && 199 | `${matchFutureReserved 200 | .map((o) => `"${o}"`) 201 | .join(', ')} (is a strict reserved character and might be used in the future)`, 202 | ] 203 | .filter(Boolean) 204 | .join(', ')}` 205 | ) 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/resolver/__tests__/ReferenceHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedTimelineObject } from '../../api' 2 | import { ExpressionHandler } from '../ExpressionHandler' 3 | import { InstanceHandler } from '../InstanceHandler' 4 | import { ReferenceHandler } from '../ReferenceHandler' 5 | import { ResolvedTimelineHandler } from '../ResolvedTimelineHandler' 6 | import { ValueWithReference, joinReferences } from '../lib/reference' 7 | 8 | const plus = (a: ValueWithReference | null, b: ValueWithReference | null): ValueWithReference | null => { 9 | if (a === null || b === null) return null 10 | return { value: a.value + b.value, references: joinReferences(a.references, b.references) } 11 | } 12 | 13 | test('operateOnArrays', () => { 14 | const resolvedTimeline = new ResolvedTimelineHandler({ time: 0 }) 15 | const instance = new InstanceHandler(resolvedTimeline) 16 | const reference = new ReferenceHandler(resolvedTimeline, instance) 17 | expect( 18 | reference.operateOnArrays( 19 | [ 20 | { id: '@a', start: 10, end: 50, references: ['#a'] }, 21 | { id: '@b', start: 30, end: 90, references: ['#b'] }, 22 | { id: '@c', start: 100, end: 110, references: ['#c'] }, 23 | ], 24 | { value: 1, references: ['#x'] }, 25 | plus 26 | ) 27 | ).toMatchObject([ 28 | { start: 11, end: 31, references: ['#a', '#x', '@@a'] }, 29 | { start: 31, end: 91, references: ['#a', '#b', '#x', '@@a', '@@b'] }, 30 | { start: 101, end: 111, references: ['#c', '#x', '@@c'] }, 31 | ]) 32 | 33 | expect( 34 | reference.operateOnArrays( 35 | [ 36 | { id: '@a', start: 10, end: 30, references: ['#a'] }, 37 | { id: '@b', start: 50, end: 70, references: ['#b'] }, 38 | { id: '@c', start: 100, end: 110, references: ['#c'] }, 39 | ], 40 | [ 41 | { id: '@x', start: 0, end: 25, references: ['#x'] }, 42 | { id: '@y', start: 0, end: 30, references: ['#y'] }, 43 | { id: '@z', start: 1, end: 5, references: ['#z'] }, 44 | ], 45 | plus 46 | ) 47 | ).toMatchObject([ 48 | { start: 10, end: 50, references: ['#a', '#x', '@@a', '@@x'] }, 49 | { start: 50, end: 100, references: ['#a', '#b', '#x', '#y', '@@a', '@@b', '@@x', '@@y'] }, 50 | { start: 101, end: 115, references: ['#c', '#z', '@@c', '@@z'] }, 51 | ]) 52 | 53 | expect(reference.operateOnArrays([{ id: '@a', start: 10, end: 30, references: ['#a'] }], null, plus)).toEqual(null) 54 | }) 55 | 56 | describe('Resolver, expressions, empty timeline', () => { 57 | const stdObj: ResolvedTimelineObject = { 58 | id: 'obj0', 59 | layer: '10', 60 | enable: {}, 61 | content: {}, 62 | resolved: { 63 | firstResolved: false, 64 | resolvedReferences: false, 65 | resolvedConflicts: false, 66 | resolving: false, 67 | instances: [], 68 | directReferences: [], 69 | isKeyframe: false, 70 | isSelfReferencing: false, 71 | levelDeep: 0, 72 | parentId: undefined, 73 | }, 74 | } 75 | const resolvedTimeline = new ResolvedTimelineHandler({ time: 0 }) 76 | const instance = new InstanceHandler(resolvedTimeline) 77 | const reference = new ReferenceHandler(resolvedTimeline, instance) 78 | const expressionHandler = new ExpressionHandler() 79 | 80 | test('expression: basic math', () => { 81 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('1+2'), 'start')).toEqual({ 82 | result: { value: 1 + 2, references: [] }, 83 | allReferences: [], 84 | }) 85 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('123-23'), 'start')).toEqual({ 86 | result: { value: 123 - 23, references: [] }, 87 | allReferences: [], 88 | }) 89 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('4*5'), 'start')).toEqual({ 90 | result: { value: 4 * 5, references: [] }, 91 | allReferences: [], 92 | }) 93 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('20/4'), 'start')).toEqual({ 94 | result: { value: 20 / 4, references: [] }, 95 | allReferences: [], 96 | }) 97 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('24%5'), 'start')).toEqual({ 98 | result: { value: 24 % 5, references: [] }, 99 | allReferences: [], 100 | }) 101 | }) 102 | test('expressions', () => { 103 | expect(expressionHandler.interpretExpression('1 + 2')).toMatchObject({ 104 | l: '1', 105 | o: '+', 106 | r: '2', 107 | }) 108 | 109 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('1 + 2'), 'start')).toEqual({ 110 | result: { value: 3, references: [] }, 111 | allReferences: [], 112 | }) 113 | 114 | expect( 115 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 + 4 - 2 + 1 - 5 + 7'), 'start') 116 | ).toEqual({ 117 | result: { value: 10, references: [] }, 118 | allReferences: [], 119 | }) 120 | 121 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 - 4 - 3'), 'start')).toEqual( 122 | { 123 | result: { value: -2, references: [] }, 124 | allReferences: [], 125 | } 126 | ) 127 | 128 | expect( 129 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 - 4 - 3 - 10 + 2'), 'start') 130 | ).toEqual({ 131 | result: { value: -10, references: [] }, 132 | allReferences: [], 133 | }) 134 | 135 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('4 * 5.5'), 'start')).toEqual({ 136 | result: { value: 22, references: [] }, 137 | allReferences: [], 138 | }) 139 | 140 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('2 * 3 * 4'), 'start')).toEqual( 141 | { 142 | result: { value: 24, references: [] }, 143 | allReferences: [], 144 | } 145 | ) 146 | 147 | expect( 148 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('20 / 4 / 2'), 'start') 149 | ).toEqual({ 150 | result: { value: 2.5, references: [] }, 151 | allReferences: [], 152 | }) 153 | 154 | expect( 155 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('2 * (2 + 3) - 2 * 2'), 'start') 156 | ).toEqual({ 157 | result: { value: 6, references: [] }, 158 | allReferences: [], 159 | }) 160 | 161 | expect( 162 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('2 * 2 + 3 - 2 * 2'), 'start') 163 | ).toEqual({ 164 | result: { value: 3, references: [] }, 165 | allReferences: [], 166 | }) 167 | 168 | expect( 169 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('2 * 2 + 3 - 2 * 2'), 'start') 170 | ).toEqual({ 171 | result: { value: 3, references: [] }, 172 | allReferences: [], 173 | }) 174 | 175 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 + -3'), 'start')).toEqual({ 176 | result: { value: 2, references: [] }, 177 | allReferences: [], 178 | }) 179 | 180 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 + - 3'), 'start')).toEqual({ 181 | result: { value: 2, references: [] }, 182 | allReferences: [], 183 | }) 184 | 185 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression(''), 'start')).toEqual({ 186 | result: null, 187 | allReferences: [], 188 | }) 189 | 190 | expect(() => { 191 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 + ) 2'), 'start') // unbalanced paranthesis 192 | }).toThrow() 193 | expect(() => { 194 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 ( + 2'), 'start') // unbalanced paranthesis 195 | }).toThrow() 196 | expect(() => { 197 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('5 * '), 'start') // unbalanced expression 198 | }).toThrow() 199 | 200 | const TRUE_EXPR = { result: [{ start: 0, end: null, references: [] }] } 201 | const FALSE_EXPR: any = { result: [] } 202 | 203 | expect( 204 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('1 | 0'), 'start') 205 | ).toMatchObject(TRUE_EXPR) 206 | expect( 207 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('1 & 0'), 'start') 208 | ).toMatchObject(FALSE_EXPR) 209 | 210 | expect( 211 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('1 | 0 & 0'), 'start') 212 | ).toMatchObject(FALSE_EXPR) 213 | 214 | expect( 215 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('0 & 1 | 1'), 'start') 216 | ).toMatchObject(FALSE_EXPR) 217 | expect( 218 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('(0 & 1) | 1'), 'start') 219 | ).toMatchObject(TRUE_EXPR) 220 | 221 | expect(() => { 222 | reference.lookupExpression(stdObj, expressionHandler.interpretExpression('(0 & 1) | 1 a'), 'start') // strange operator 223 | }).toThrow() 224 | 225 | expect( 226 | reference.lookupExpression( 227 | stdObj, 228 | expressionHandler.interpretExpression('(0 & 1) | a'), 229 | 'start' // strange operand 230 | ) 231 | ).toEqual({ result: null, allReferences: [] }) 232 | 233 | expect( 234 | reference.lookupExpression( 235 | stdObj, 236 | expressionHandler.interpretExpression('14 + #badReference.start'), 237 | 'start' 238 | ) 239 | ).toEqual({ 240 | result: [], 241 | allReferences: ['#badReference'], 242 | }) 243 | 244 | expect(reference.lookupExpression(stdObj, expressionHandler.interpretExpression('1'), 'start')).toBeTruthy() 245 | 246 | // @ts-expect-error wrong expression type 247 | expect(reference.lookupExpression(stdObj, false, 'start')).toEqual({ result: null, allReferences: [] }) 248 | }) 249 | }) 250 | describe('Resolver, expressions, filledtimeline', () => { 251 | const obj: ResolvedTimelineObject = { 252 | id: 'obj0', 253 | layer: '10', 254 | enable: {}, 255 | content: {}, 256 | resolved: { 257 | firstResolved: false, 258 | resolvedReferences: false, 259 | resolvedConflicts: false, 260 | resolving: false, 261 | instances: [], 262 | directReferences: [], 263 | isKeyframe: false, 264 | isSelfReferencing: false, 265 | levelDeep: 0, 266 | parentId: undefined, 267 | }, 268 | } 269 | const resolvedTimeline = new ResolvedTimelineHandler({ time: 0 }) 270 | const instance = new InstanceHandler(resolvedTimeline) 271 | const reference = new ReferenceHandler(resolvedTimeline, instance) 272 | const expressionHandler = new ExpressionHandler() 273 | 274 | resolvedTimeline.addTimelineObject({ 275 | id: 'first', 276 | layer: '0', 277 | enable: { 278 | start: 0, 279 | end: 100, 280 | }, 281 | content: {}, 282 | }) 283 | resolvedTimeline.addTimelineObject({ 284 | id: 'second', 285 | layer: '1', 286 | enable: { 287 | start: 20, 288 | end: 120, 289 | }, 290 | content: {}, 291 | }) 292 | resolvedTimeline.addTimelineObject({ 293 | id: 'third', 294 | layer: '2', 295 | enable: { 296 | start: 40, 297 | end: 130, 298 | }, 299 | content: {}, 300 | }) 301 | resolvedTimeline.addTimelineObject({ 302 | id: 'fourth', 303 | layer: '3', 304 | enable: { 305 | start: 40, 306 | end: null, // never-ending 307 | }, 308 | content: {}, 309 | }) 310 | resolvedTimeline.addTimelineObject({ 311 | id: 'middle', 312 | layer: '4', 313 | enable: { 314 | start: 25, 315 | end: 35, 316 | }, 317 | content: {}, 318 | }) 319 | 320 | test('lookupExpression', () => { 321 | expect(reference.lookupExpression(obj, expressionHandler.interpretExpression('#unknown'), 'start')).toEqual({ 322 | result: [], 323 | allReferences: ['#unknown'], 324 | }) 325 | expect(reference.lookupExpression(obj, expressionHandler.interpretExpression('#first'), 'start')).toMatchObject( 326 | { 327 | result: [ 328 | { 329 | start: 0, 330 | end: 100, 331 | }, 332 | ], 333 | } 334 | ) 335 | expect( 336 | reference.lookupExpression(obj, expressionHandler.interpretExpression('#first.start'), 'start') 337 | ).toMatchObject({ 338 | result: [ 339 | { 340 | start: 0, 341 | end: 100, 342 | }, 343 | ], 344 | }) 345 | 346 | expect( 347 | reference.lookupExpression(obj, expressionHandler.interpretExpression('#first & #second'), 'start') 348 | ).toMatchObject({ 349 | result: [ 350 | { 351 | start: 20, 352 | end: 100, 353 | }, 354 | ], 355 | }) 356 | 357 | expect( 358 | reference.lookupExpression( 359 | obj, 360 | expressionHandler.interpretExpression('(#first & #second) | #third'), 361 | 'start' 362 | ) 363 | ).toMatchObject({ 364 | result: [ 365 | { 366 | start: 20, 367 | end: 130, 368 | }, 369 | ], 370 | }) 371 | expect( 372 | reference.lookupExpression( 373 | obj, 374 | expressionHandler.interpretExpression('#first & #second & !#middle'), 375 | 'start' 376 | ) 377 | ).toMatchObject({ 378 | result: [ 379 | { 380 | start: 20, 381 | end: 25, 382 | }, 383 | { 384 | start: 35, 385 | end: 100, 386 | }, 387 | ], 388 | }) 389 | 390 | expect( 391 | reference.lookupExpression(obj, expressionHandler.interpretExpression('#first + 5'), 'start') 392 | ).toMatchObject({ 393 | result: [ 394 | { 395 | start: 5, 396 | end: 105, 397 | }, 398 | ], 399 | }) 400 | expect( 401 | reference.lookupExpression( 402 | obj, 403 | expressionHandler.interpretExpression('((#first & !#second) | #middle) + 1'), 404 | 'start' 405 | ) 406 | ).toMatchObject({ 407 | result: [ 408 | { 409 | start: 1, 410 | end: 21, 411 | }, 412 | { 413 | start: 26, 414 | end: 36, 415 | }, 416 | ], 417 | }) 418 | }) 419 | }) 420 | -------------------------------------------------------------------------------- /src/resolver/__tests__/ResolvedTimelineHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedTimelineHandler } from '../ResolvedTimelineHandler' 2 | import { baseInstances } from '../lib/instance' 3 | 4 | describe('ResolvedTimelineHandler', () => { 5 | test('capInstancesToParentInstances', () => { 6 | const handler = new ResolvedTimelineHandler({ time: 0 }) 7 | 8 | expect( 9 | handler.capInstancesToParentInstances({ 10 | instances: [{ id: '@i0', references: [], start: 0, end: 500 }], 11 | parentInstances: [{ id: '@p0', references: [], start: 20, end: 30 }], 12 | }) 13 | ).toMatchObject([{ start: 20, end: 30 }]) 14 | 15 | expect( 16 | handler.capInstancesToParentInstances({ 17 | instances: [{ id: '@i0', references: [], start: 10, end: 500 }], 18 | parentInstances: [{ id: '@p0', references: [], start: 0, end: 30 }], 19 | }) 20 | ).toMatchObject([{ start: 10, end: 30 }]) 21 | 22 | expect( 23 | handler.capInstancesToParentInstances({ 24 | instances: [{ id: '@i0', references: [], start: 10, end: 500 }], 25 | parentInstances: [{ id: '@p0', references: [], start: 0, end: 100 }], 26 | }) 27 | ).toMatchObject([{ start: 10, end: 100 }]) 28 | { 29 | expect( 30 | baseInstances( 31 | handler.capInstancesToParentInstances({ 32 | instances: [ 33 | { id: '@i0', references: [], start: 0, end: 499 }, 34 | { id: '@i1', references: [], start: 500, end: 1000 }, 35 | ], 36 | parentInstances: [ 37 | { id: '@p0', references: [], start: 0, end: 1 }, 38 | { id: '@p1', references: [], start: 500, end: 501 }, 39 | ], 40 | }) 41 | ) 42 | ).toMatchObject([ 43 | { start: 0, end: 1 }, 44 | { start: 500, end: 501 }, 45 | ]) 46 | } 47 | { 48 | expect( 49 | baseInstances( 50 | handler.capInstancesToParentInstances({ 51 | instances: [ 52 | { id: '@i0', references: [], start: 0, end: 1 }, 53 | { id: '@i1', references: [], start: 500, end: 501 }, 54 | { id: '@i2', references: [], start: 501, end: 2501 }, 55 | ], 56 | parentInstances: [ 57 | { id: '@p0', references: [], start: 0, end: 1 }, 58 | { id: '@p1', references: [], start: 500, end: 501 }, 59 | { id: '@p2', references: [], start: 501, end: null }, 60 | ], 61 | }) 62 | ) 63 | ).toMatchObject([ 64 | { start: 0, end: 1 }, 65 | { start: 500, end: 501 }, 66 | { start: 501, end: 2501 }, 67 | ]) 68 | } 69 | { 70 | expect( 71 | baseInstances( 72 | handler.capInstancesToParentInstances({ 73 | instances: [ 74 | { id: '@i0', references: [], start: 0, end: 499 }, 75 | { id: '@i1', references: [], start: 500, end: 501 }, 76 | { id: '@i2', references: [], start: 501, end: 2501 }, 77 | ], 78 | parentInstances: [ 79 | { id: '@p0', references: [], start: 0, end: 1 }, 80 | { id: '@p1', references: [], start: 500, end: 501 }, 81 | ], 82 | }) 83 | ) 84 | ).toMatchObject([ 85 | { start: 0, end: 1 }, 86 | { start: 500, end: 501 }, 87 | { start: 501, end: 501 }, 88 | ]) 89 | } 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /src/resolver/__tests__/StateHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { StateHandler } from '../StateHandler' 2 | 3 | describe('state', () => { 4 | test('applyKeyframeContent', () => { 5 | const o: any = {} 6 | const o2: any = { 7 | a: 1, 8 | b: { 9 | c: 2, 10 | }, 11 | c: [ 12 | 1, 13 | { 14 | a: 3, 15 | }, 16 | ], 17 | } 18 | StateHandler.applyKeyframeContent(o, o2) 19 | expect(o).toEqual(o2) 20 | 21 | StateHandler.applyKeyframeContent(o, { 22 | b: { c: 4, d: 42 }, 23 | }) 24 | o2.b = { c: 4, d: 42 } 25 | expect(o).toEqual(o2) 26 | 27 | StateHandler.applyKeyframeContent(o, { 28 | c: [5], 29 | }) 30 | o2.c = [5] 31 | expect(o).toEqual(o2) 32 | 33 | StateHandler.applyKeyframeContent(o, { 34 | c: [ 35 | { a: 1, b: 2 }, 36 | { a: 3, b: 4 }, 37 | ], 38 | }) 39 | o2.c = [ 40 | { a: 1, b: 2 }, 41 | { a: 3, b: 4 }, 42 | ] 43 | expect(o).toEqual(o2) 44 | 45 | StateHandler.applyKeyframeContent(o, { 46 | c: { b: 1 }, 47 | }) 48 | o2.c = { b: 1 } 49 | expect(o).toEqual(o2) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/resolver/__tests__/TimelineValidator.spec.ts: -------------------------------------------------------------------------------- 1 | import { TimelineValidator } from '../TimelineValidator' 2 | 3 | test('validateReferenceString', () => { 4 | // Happy path: 5 | expect(() => TimelineValidator.validateReferenceString('asdf123456789')).not.toThrow() 6 | expect(() => TimelineValidator.validateReferenceString('asdfABC_2')).not.toThrow() 7 | 8 | // Contains operands: 9 | expect(() => TimelineValidator.validateReferenceString('abc+1')).toThrow( 10 | /contains characters which aren't allowed in Timeline: "\+" \(is an operator\)/ 11 | ) 12 | expect(() => TimelineValidator.validateReferenceString('abc-1')).toThrow( 13 | /contains characters which aren't allowed in Timeline: "-" \(is an operator\)/ 14 | ) 15 | expect(() => TimelineValidator.validateReferenceString('abc-1+1')).toThrow( 16 | /contains characters which aren't allowed in Timeline: "-", "\+" \(is an operator\)/ 17 | ) 18 | 19 | // Contains reserved characters: 20 | expect(() => TimelineValidator.validateReferenceString('abc#')).toThrow( 21 | /contains characters which aren't allowed in Timeline: "#" \(is a reserved character\)/ 22 | ) 23 | expect(() => TimelineValidator.validateReferenceString('abc$')).toThrow( 24 | /contains characters which aren't allowed in Timeline: "\$" \(is a reserved character\)/ 25 | ) 26 | expect(() => TimelineValidator.validateReferenceString('abc.2')).toThrow( 27 | /contains characters which aren't allowed in Timeline: "\." \(is a reserved character\)/ 28 | ) 29 | expect(() => TimelineValidator.validateReferenceString('abc#2.4')).toThrow( 30 | /contains characters which aren't allowed in Timeline: "#", "\." \(is a reserved character\)/ 31 | ) 32 | 33 | // Contains reserved future characters: 34 | expect(() => TimelineValidator.validateReferenceString('abc?')).not.toThrow() 35 | expect(() => TimelineValidator.validateReferenceString('abc=')).not.toThrow() 36 | expect(() => TimelineValidator.validateReferenceString('abc§')).not.toThrow() 37 | 38 | // Contains reserved future characters (strict): 39 | expect(() => TimelineValidator.validateReferenceString('abc?', true)).toThrow( 40 | /contains characters which aren't allowed in Timeline: "\?" \(is a strict reserved character/ 41 | ) 42 | expect(() => TimelineValidator.validateReferenceString('abc=', true)).toThrow( 43 | /contains characters which aren't allowed in Timeline: "=" \(is a strict reserved character/ 44 | ) 45 | expect(() => TimelineValidator.validateReferenceString('abc§', true)).toThrow( 46 | /contains characters which aren't allowed in Timeline: "§" \(is a strict reserved character/ 47 | ) 48 | expect(() => TimelineValidator.validateReferenceString('abc§=4', true)).toThrow( 49 | /contains characters which aren't allowed in Timeline: "§", "=" \(is a strict reserved character/ 50 | ) 51 | 52 | // Multiple issues: 53 | expect(() => TimelineValidator.validateReferenceString('abc§=4-1', true)).toThrow( 54 | /contains characters which aren't allowed in Timeline: "-" \(is an operator\), "§", "=" \(is a strict reserved character/ 55 | ) 56 | }) 57 | test('validateObject', () => { 58 | const validator = new TimelineValidator() 59 | expect(() => 60 | validator.validateObject( 61 | { 62 | id: 'obj0', 63 | content: {}, 64 | enable: { 65 | start: 0, 66 | duration: 0, 67 | end: 0, // end and duration cannot be combined 68 | }, 69 | layer: 'L1', 70 | }, 71 | true 72 | ) 73 | ).toThrow(/obj0.*end.*duration.*cannot be combined/) 74 | }) 75 | -------------------------------------------------------------------------------- /src/resolver/lib/Error.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedTimeline } from '../../api' 2 | 3 | export class ResolveError extends Error { 4 | constructor(e: unknown, public readonly resolvedTimeline: ResolvedTimeline) { 5 | super(e instanceof Error ? e.message : `${e}`) 6 | 7 | this.name = 'ResolveError' 8 | if (e instanceof Error) { 9 | this.stack = e.stack 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/resolver/lib/__tests__/event.spec.ts: -------------------------------------------------------------------------------- 1 | import { sortEvents } from '../event' 2 | 3 | test('sortEvents', () => { 4 | expect( 5 | sortEvents([ 6 | { time: 300, value: true, references: ['@@a'], data: {} }, 7 | { time: 2, value: false, references: ['@@a'], data: {} }, 8 | { time: 100, value: true, references: ['@@a'], data: {} }, 9 | { time: 3, value: true, references: ['@@a'], data: {} }, 10 | { time: 20, value: false, references: ['@@a'], data: {} }, 11 | { time: 2, value: true, references: ['@@a'], data: {} }, 12 | { time: 100, value: false, references: ['@@a'], data: {} }, 13 | { time: 20, value: true, references: ['@@a'], data: {} }, 14 | { time: 1, value: true, references: ['@@a'], data: {} }, 15 | ]) 16 | ).toEqual([ 17 | { time: 1, value: true, references: ['@@a'], data: {} }, 18 | { time: 2, value: false, references: ['@@a'], data: {} }, 19 | { time: 2, value: true, references: ['@@a'], data: {} }, 20 | { time: 3, value: true, references: ['@@a'], data: {} }, 21 | { time: 20, value: false, references: ['@@a'], data: {} }, 22 | { time: 20, value: true, references: ['@@a'], data: {} }, 23 | { time: 100, value: false, references: ['@@a'], data: {} }, 24 | { time: 100, value: true, references: ['@@a'], data: {} }, 25 | { time: 300, value: true, references: ['@@a'], data: {} }, 26 | ]) 27 | }) 28 | -------------------------------------------------------------------------------- /src/resolver/lib/__tests__/expression.spec.ts: -------------------------------------------------------------------------------- 1 | import { isConstantExpr, isNumericExpr } from '../expression' 2 | 3 | describe('lib', () => { 4 | test('isConstantExpr', () => { 5 | expect(isConstantExpr('1')).toEqual(true) 6 | expect(isConstantExpr('0')).toEqual(true) 7 | expect(isConstantExpr('true')).toEqual(true) 8 | expect(isConstantExpr('false')).toEqual(true) 9 | expect(isConstantExpr('asdf')).toEqual(false) 10 | expect(isConstantExpr('.asdf')).toEqual(false) 11 | }) 12 | test('isNumeric', () => { 13 | expect(isNumericExpr('123')).toEqual(true) 14 | expect(isNumericExpr('123.234')).toEqual(true) 15 | expect(isNumericExpr('-23123.234')).toEqual(true) 16 | expect(isNumericExpr('123a')).toEqual(false) 17 | expect(isNumericExpr('123,1')).toEqual(false) 18 | expect(isNumericExpr('asdf')).toEqual(false) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/resolver/lib/__tests__/instance.spec.ts: -------------------------------------------------------------------------------- 1 | import { getInstanceIntersection, spliceInstances } from '../instance' 2 | 3 | test('getInstanceIntersection', () => { 4 | expect(getInstanceIntersection({ start: 10, end: 20 }, { start: 1, end: 13 })).toMatchObject({ start: 10, end: 13 }) 5 | expect(getInstanceIntersection({ start: 1, end: 13 }, { start: 10, end: 20 })).toMatchObject({ start: 10, end: 13 }) 6 | 7 | expect(getInstanceIntersection({ start: 10, end: 20 }, { start: 11, end: 13 })).toMatchObject({ 8 | start: 11, 9 | end: 13, 10 | }) 11 | expect(getInstanceIntersection({ start: 10, end: 20 }, { start: 1, end: 25 })).toMatchObject({ start: 10, end: 20 }) 12 | 13 | expect(getInstanceIntersection({ start: 10, end: 20 }, { start: 21, end: 25 })).toBeNull() 14 | expect(getInstanceIntersection({ start: 10, end: 20 }, { start: 1, end: null })).toMatchObject({ 15 | start: 10, 16 | end: 20, 17 | }) 18 | expect(getInstanceIntersection({ start: 10, end: 20 }, { start: 15, end: null })).toMatchObject({ 19 | start: 15, 20 | end: 20, 21 | }) 22 | 23 | expect(getInstanceIntersection({ start: 10, end: 20 }, { start: 20, end: null })).toBeNull() 24 | 25 | expect(getInstanceIntersection({ start: 50, end: 70 }, { start: 60, end: 60 })).toMatchObject({ 26 | start: 60, 27 | end: 60, 28 | }) 29 | 30 | expect(getInstanceIntersection({ start: 50, end: null }, { start: 40, end: 70 })).toMatchObject({ 31 | start: 50, 32 | end: 70, 33 | }) 34 | 35 | expect(getInstanceIntersection({ start: 50, end: null }, { start: 40, end: null })).toMatchObject({ 36 | start: 50, 37 | end: null, 38 | }) 39 | }) 40 | 41 | test('spliceInstances', () => { 42 | const instances = [ 43 | { start: 10, end: 15 }, 44 | { start: 20, end: 25 }, 45 | { start: 30, end: 35 }, 46 | { start: 40, end: 45 }, 47 | { start: 50, end: 55 }, 48 | ] 49 | spliceInstances(instances, (i) => { 50 | if (i.start === 10) return { start: 11, end: 16 } 51 | if (i.start === 20) return undefined 52 | if (i.start === 30) return [] 53 | if (i.start === 40) 54 | return [ 55 | { start: 41, end: 42 }, 56 | { start: 45, end: 46 }, 57 | ] 58 | 59 | return i 60 | }) 61 | expect(instances).toEqual([ 62 | { start: 11, end: 16 }, 63 | // { start: 20, end: 25 }, 64 | // { start: 30, end: 35 }, 65 | { start: 41, end: 42 }, 66 | { start: 45, end: 46 }, 67 | { start: 50, end: 55 }, 68 | ]) 69 | }) 70 | -------------------------------------------------------------------------------- /src/resolver/lib/__tests__/lib.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | literal, 3 | compact, 4 | last, 5 | isObject, 6 | reduceObj, 7 | pushToArray, 8 | clone, 9 | uniq, 10 | omit, 11 | sortBy, 12 | isEmpty, 13 | ensureArray, 14 | isArray, 15 | assertNever, 16 | } from '../lib' 17 | 18 | test('literal', () => { 19 | expect(literal('x')).toBe('x') 20 | }) 21 | test('compact', () => { 22 | expect(compact([0, 1, 2, 3, 4, '', null, undefined, '1234'])).toStrictEqual([0, 1, 2, 3, 4, '1234']) 23 | }) 24 | test('last', () => { 25 | expect(last([1, 2, 3, 4])).toBe(4) 26 | }) 27 | test('isObject', () => { 28 | expect(isObject({ a: 1 })).toBe(true) 29 | expect(isObject(null)).toBe(false) 30 | expect(isObject([])).toBe(true) 31 | }) 32 | test('reduceObj', () => { 33 | expect(reduceObj({ a: 1, b: 2, c: 3 }, (memo, value, key) => memo + value + key, 'start')).toBe('start1a2b3c') 34 | }) 35 | test('pushToArray', () => { 36 | const a = [1, 2] 37 | pushToArray(a, [3, 4]) 38 | expect(a).toStrictEqual([1, 2, 3, 4]) 39 | }) 40 | test('clone', () => { 41 | expect(clone({ a: 1 })).toStrictEqual({ a: 1 }) 42 | }) 43 | test('uniq', () => { 44 | expect(uniq([1, 2, 3, 1])).toStrictEqual([1, 2, 3]) 45 | }) 46 | test('omit', () => { 47 | expect(omit({ a: 1, b: 2 }, 'a')).toStrictEqual({ b: 2 }) 48 | }) 49 | test('sortBy', () => { 50 | expect( 51 | sortBy( 52 | [ 53 | { a: 1, b: 2 }, 54 | { a: 2, b: 1 }, 55 | ], 56 | (o) => o.b 57 | ) 58 | ).toStrictEqual([ 59 | { b: 1, a: 2 }, 60 | { b: 2, a: 1 }, 61 | ]) 62 | }) 63 | test('isEmpty', () => { 64 | expect(isEmpty({})).toBe(true) 65 | expect(isEmpty({ a: 1 })).toBe(false) 66 | }) 67 | test('ensureArray', () => { 68 | expect(ensureArray({ a: 1 })).toStrictEqual([{ a: 1 }]) 69 | expect(ensureArray([{ a: 1 }])).toStrictEqual([{ a: 1 }]) 70 | }) 71 | test('isArray', () => { 72 | expect(isArray({ a: 1 })).toEqual(false) 73 | expect(isArray([{ a: 1 }])).toEqual(true) 74 | }) 75 | test('assertNever', () => { 76 | const fcn = (value: 'a' | 'b'): void => { 77 | if (value === 'a') return 78 | if (value === 'b') return 79 | assertNever(value) 80 | } 81 | 82 | expect(() => fcn('a')).not.toThrow() 83 | expect(() => fcn('b')).not.toThrow() 84 | // @ts-expect-error bad argument 85 | expect(() => fcn('c')).not.toThrow() 86 | }) 87 | -------------------------------------------------------------------------------- /src/resolver/lib/cache.ts: -------------------------------------------------------------------------------- 1 | export class Cache { 2 | private cache = new Map() 3 | 4 | private clearTimeout: NodeJS.Timer | undefined = undefined 5 | private timeToCueNewCleanup = false 6 | 7 | constructor(private autoCleanup: boolean = false) { 8 | if (this.autoCleanup) this.timeToCueNewCleanup = true 9 | } 10 | 11 | /** Cache the result of function for a limited time */ 12 | public cacheResult(key: string, fcn: () => T, limitTime: number): T { 13 | const cache = this.cache.get(key) 14 | if (!cache || cache.ttl < Date.now()) { 15 | const value = fcn() 16 | this.cache.set(key, { 17 | ttl: Date.now() + limitTime, 18 | value: value, 19 | }) 20 | 21 | if (this.timeToCueNewCleanup) { 22 | this.timeToCueNewCleanup = false 23 | /* istanbul ignore next */ 24 | this.clearTimeout = setTimeout(() => { 25 | this.clearTimeout = undefined 26 | this.timeToCueNewCleanup = true 27 | this.cleanUp() 28 | }, limitTime + 100) 29 | } 30 | 31 | return value 32 | } else { 33 | return cache.value 34 | } 35 | } 36 | /* istanbul ignore next */ 37 | public cleanUp(): void { 38 | const now = Date.now() 39 | for (const [key, value] of this.cache.entries()) { 40 | if (value.ttl < now) this.cache.delete(key) 41 | } 42 | } 43 | public clear(): void { 44 | this.cache.clear() 45 | if (this.clearTimeout) { 46 | clearTimeout(this.clearTimeout) 47 | this.clearTimeout = undefined 48 | this.timeToCueNewCleanup = true 49 | } 50 | } 51 | } 52 | 53 | interface CacheEntry { 54 | ttl: number 55 | value: any 56 | } 57 | -------------------------------------------------------------------------------- /src/resolver/lib/cap.ts: -------------------------------------------------------------------------------- 1 | import { Cap, TimelineObjectInstance } from '../../api' 2 | 3 | export function joinCaps(...caps: Array): Cap[] { 4 | const capMap: { [capReference: string]: Cap } = {} 5 | for (let i = 0; i < caps.length; i++) { 6 | const caps2 = caps[i] 7 | if (caps2) { 8 | for (let j = 0; j < caps2.length; j++) { 9 | const cap2 = caps2[j] 10 | capMap[cap2.id] = cap2 11 | } 12 | } 13 | } 14 | return Object.values(capMap) 15 | } 16 | export function addCapsToResuming(instance: TimelineObjectInstance, ...caps: Array): void { 17 | const capsToAdd: Cap[] = [] 18 | const joinedCaps = joinCaps(...caps) 19 | for (let i = 0; i < joinedCaps.length; i++) { 20 | const cap = joinedCaps[i] 21 | 22 | if (cap.end !== null && instance.end !== null && cap.end > instance.end) { 23 | capsToAdd.push({ 24 | id: cap.id, 25 | start: 0, 26 | end: cap.end, 27 | }) 28 | } 29 | } 30 | instance.caps = joinCaps(instance.caps, capsToAdd) 31 | } 32 | -------------------------------------------------------------------------------- /src/resolver/lib/event.ts: -------------------------------------------------------------------------------- 1 | import { Time, TimelineObjectInstance, Reference } from '../../api' 2 | 3 | export interface InstanceEvent { 4 | time: Time 5 | value: boolean 6 | references: Reference[] 7 | data: T 8 | } 9 | export type EventForInstance = InstanceEvent<{ 10 | id?: string 11 | instance: TimelineObjectInstance 12 | notANegativeInstance?: boolean 13 | }> 14 | 15 | export function sortEvents( 16 | events: T[], 17 | additionalSortFcnBefore?: (a: T, b: T) => number 18 | ): T[] { 19 | return events.sort((a: T, b: T) => { 20 | if (a.time > b.time) return 1 21 | if (a.time < b.time) return -1 22 | 23 | const result = additionalSortFcnBefore ? additionalSortFcnBefore(a, b) : 0 24 | if (result !== 0) return result 25 | 26 | const aId = a.data && (a.data.id || a.data.instance?.id) 27 | const bId = b.data && (b.data.id || b.data.instance?.id) 28 | if (aId && bId && aId === bId) { 29 | // If the events refer to the same instance id, let the start event be first, 30 | // to handle zero-length instances. 31 | if (a.value && !b.value) return -1 32 | if (!a.value && b.value) return 1 33 | } else { 34 | // ends events first: 35 | if (a.value && !b.value) return 1 36 | if (!a.value && b.value) return -1 37 | } 38 | return 0 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/resolver/lib/expression.ts: -------------------------------------------------------------------------------- 1 | import { Expression } from '../../api' 2 | 3 | /** Returns true if an expression is a constant (ie doesn't reference something else) */ 4 | export function isConstantExpr(str: string | number | null | Expression): str is string | number { 5 | if (isNumericExpr(str)) return true 6 | if (typeof str === 'string') { 7 | const lStr = str.toLowerCase() 8 | if (lStr === 'true') return true 9 | if (lStr === 'false') return true 10 | } 11 | return false 12 | } 13 | export function isNumericExpr(str: string | number | null | Expression): boolean { 14 | if (str === null) return false 15 | if (typeof str === 'number') return true 16 | if (typeof str === 'string') return !!/^[-+]?[0-9.]+$/.exec(str) && !isNaN(parseFloat(str)) 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /src/resolver/lib/instance.ts: -------------------------------------------------------------------------------- 1 | import { Time, InstanceBase, TimelineObjectInstance, InstanceId } from '../../api' 2 | import { ensureArray } from './lib' 3 | 4 | export function isInstanceId(str: string): str is InstanceId { 5 | return str.startsWith('@') 6 | } 7 | 8 | export function instanceIsActive(instance: InstanceBase, time: Time): boolean { 9 | return instance.start <= time && (instance.end ?? Infinity) > time 10 | } 11 | /** 12 | * Returns the intersection of two instances. 13 | * Example: for (10-20) and (15-30), the intersection is (15-20). 14 | */ 15 | export function getInstanceIntersection(a: InstanceBase, b: InstanceBase): null | InstanceBase { 16 | if (a.start < (b.end ?? Infinity) && (a.end ?? Infinity) > b.start) { 17 | const start = Math.max(a.start, b.start) 18 | const end = Math.min(a.end ?? Infinity, b.end ?? Infinity) 19 | 20 | return { 21 | start, 22 | end: end === Infinity ? null : end, 23 | } 24 | } 25 | return null 26 | } 27 | 28 | /** 29 | * Convenience function to splice an array of instances 30 | * @param instances The array of instances to splice 31 | * @param fcn Operator function. 32 | * Is called for each instance in the array, 33 | * and should return an instance (or an array of instances) to insert in place of the original instance, 34 | * or undefined to remove the instance. 35 | * (To leave the instance unchanged, return the original instance) 36 | */ 37 | export function spliceInstances( 38 | instances: I[], 39 | fcn: (instance: I) => I[] | I | undefined 40 | ): void { 41 | for (let i = 0; i < instances.length; i++) { 42 | const fcnResult = fcn(instances[i]) 43 | const insertInstances: I[] = fcnResult === undefined ? [] : ensureArray(fcnResult) 44 | 45 | if (insertInstances.length === 0) { 46 | instances.splice(i, 1) 47 | i-- 48 | } else { 49 | if (insertInstances[0] === instances[i]) continue 50 | 51 | // replace: 52 | instances.splice(i, 1, ...insertInstances) 53 | i += insertInstances.length - 1 54 | } 55 | } 56 | } 57 | 58 | export function baseInstances(instances: TimelineObjectInstance[]): InstanceBase[] { 59 | return instances.map((instance) => baseInstance(instance)) 60 | } 61 | export function baseInstance(instance: TimelineObjectInstance): InstanceBase { 62 | return { 63 | start: instance.start, 64 | end: instance.end, 65 | } 66 | } 67 | 68 | /** Returns a string hash that changes whenever any instance has changed in a significant way */ 69 | export function getInstancesHash(instances: TimelineObjectInstance[]): string { 70 | const strs: string[] = [] 71 | for (const instance of instances) { 72 | strs.push(getInstanceHash(instance)) 73 | } 74 | return strs.join(',') 75 | } 76 | /** Returns a string hash that changes whenever an instance has changed in a significant way */ 77 | export function getInstanceHash(instance: TimelineObjectInstance): string { 78 | const orgStart = instance.originalStart ?? instance.start 79 | const orgEnd = instance.originalEnd ?? instance.end 80 | 81 | return `${instance.start}_${instance.end ?? 'null'}(${orgStart}_${orgEnd ?? 'null'})` 82 | } 83 | -------------------------------------------------------------------------------- /src/resolver/lib/lib.ts: -------------------------------------------------------------------------------- 1 | export function literal(o: T): T { 2 | return o 3 | } 4 | 5 | export function compact(arr: (T | undefined | null)[]): T[] { 6 | const returnValues: T[] = [] 7 | for (let i = 0; i < arr.length; i++) { 8 | const v = arr[i] 9 | if (!!v || (v !== undefined && v !== null && v !== '')) returnValues.push(v) 10 | } 11 | return returnValues 12 | } 13 | export function last(arr: T[]): T | undefined { 14 | return arr[arr.length - 1] 15 | } 16 | /** Returns true if argument is an object (or an array, but NOT null) */ 17 | export function isObject(o: unknown): o is object { 18 | return o !== null && typeof o === 'object' 19 | } 20 | 21 | export function reduceObj( 22 | objs: { [key: string]: V }, 23 | fcn: (memo: R, value: V, key: string, index: number) => R, 24 | initialValue: R 25 | ): R { 26 | return Object.entries(objs).reduce((memo, [key, value], index) => { 27 | return fcn(memo, value, key, index) 28 | }, initialValue) 29 | } 30 | 31 | /** 32 | * Concatenate two arrays of values. 33 | * This is a convenience function used to ensure that the two arrays are of the same type. 34 | * @param arr0 The array of values to push into 35 | * @param arr1 An array of values to push into arr0 36 | */ 37 | export function pushToArray(arr0: T[], arr1: T[]): void { 38 | for (const item of arr1) { 39 | arr0.push(item) 40 | } 41 | } 42 | export function clone(obj: T): T { 43 | return JSON.parse(JSON.stringify(obj)) 44 | } 45 | export function uniq(arr: T[]): T[] { 46 | return Array.from(new Set(arr)) 47 | } 48 | 49 | type _Omit = V extends never 50 | ? any 51 | : Extract extends never 52 | ? Partial 53 | : Pick> 54 | 55 | export function omit(obj: V, ...keys: (K | K[])[]): _Omit { 56 | const result: any = {} 57 | for (const [key, value] of Object.entries(obj)) { 58 | if (keys.some((k) => (Array.isArray(k) ? k.includes(key as K) : k === key))) continue 59 | result[key] = value 60 | } 61 | 62 | return result 63 | } 64 | export function sortBy(arr: T[], fcn: (value: T) => string | number): T[] { 65 | const sortArray = arr.map((item) => ({ item, value: fcn(item) })) 66 | sortArray.sort((a, b) => { 67 | if (a.value < b.value) return -1 68 | if (a.value > b.value) return 1 69 | 70 | return 0 71 | }) 72 | return sortArray.map((item) => item.item) 73 | } 74 | export function isEmpty(obj: object): boolean { 75 | return Object.keys(obj).length === 0 76 | } 77 | 78 | export function ensureArray(value: T | T[]): T[] { 79 | return Array.isArray(value) ? value : [value] 80 | } 81 | /** 82 | * Slightly faster than Array.isArray(). 83 | * Note: Ensure that the value provided is not null! 84 | */ 85 | export function isArray(arg: object | any[]): arg is any[] { 86 | // Fast-path optimization: checking for .length is faster than Array.isArray() 87 | 88 | return (arg as any).length !== undefined && Array.isArray(arg) 89 | } 90 | /** 91 | * Helper function to simply assert that the value is of the type never. 92 | * Usage: at the end of if/else or switch, to ensure that there is no fallthrough. 93 | */ 94 | export function assertNever(_value: never): void { 95 | // does nothing 96 | } 97 | 98 | export function mapToObject(map: Map): { [key: string]: T } { 99 | const o: { [key: string]: T } = {} 100 | for (const [key, value] of map.entries()) { 101 | o[key] = value 102 | } 103 | return o 104 | } 105 | 106 | export function compareStrings(a: string, b: string): number { 107 | return a > b ? 1 : a < b ? -1 : 0 108 | } 109 | -------------------------------------------------------------------------------- /src/resolver/lib/operator.ts: -------------------------------------------------------------------------------- 1 | import { assertNever } from './lib' 2 | import { ValueWithReference, joinReferences } from './reference' 3 | 4 | export type OperatorFunction = (a: ValueWithReference | null, b: ValueWithReference | null) => ValueWithReference | null 5 | 6 | /** Helper class for various math operators, used in expressions */ 7 | export abstract class Operator { 8 | static get(operator: '+' | '-' | '*' | '/' | '%'): OperatorFunction { 9 | switch (operator) { 10 | case '+': 11 | return Operator.Add 12 | case '-': 13 | return Operator.Subtract 14 | case '*': 15 | return Operator.Multiply 16 | case '/': 17 | return Operator.Divide 18 | case '%': 19 | return Operator.Modulo 20 | default: { 21 | /* istanbul ignore next */ 22 | assertNever(operator) 23 | /* istanbul ignore next */ 24 | return Operator.Null 25 | } 26 | } 27 | } 28 | 29 | private static Add = (a: ValueWithReference | null, b: ValueWithReference | null): ValueWithReference | null => { 30 | if (a === null || b === null) return null 31 | return { 32 | value: a.value + b.value, 33 | references: joinReferences(a.references, b.references), 34 | } 35 | } 36 | private static Subtract = ( 37 | a: ValueWithReference | null, 38 | b: ValueWithReference | null 39 | ): ValueWithReference | null => { 40 | if (a === null || b === null) return null 41 | return { 42 | value: a.value - b.value, 43 | references: joinReferences(a.references, b.references), 44 | } 45 | } 46 | private static Multiply = ( 47 | a: ValueWithReference | null, 48 | b: ValueWithReference | null 49 | ): ValueWithReference | null => { 50 | if (a === null || b === null) return null 51 | return { 52 | value: a.value * b.value, 53 | references: joinReferences(a.references, b.references), 54 | } 55 | } 56 | private static Divide = (a: ValueWithReference | null, b: ValueWithReference | null): ValueWithReference | null => { 57 | if (a === null || b === null) return null 58 | return { 59 | value: a.value / b.value, 60 | references: joinReferences(a.references, b.references), 61 | } 62 | } 63 | private static Modulo = (a: ValueWithReference | null, b: ValueWithReference | null): ValueWithReference | null => { 64 | if (a === null || b === null) return null 65 | return { 66 | value: a.value % b.value, 67 | references: joinReferences(a.references, b.references), 68 | } 69 | } 70 | private static Null = (): ValueWithReference | null => { 71 | return null 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/resolver/lib/performance.ts: -------------------------------------------------------------------------------- 1 | let durations: { [name: string]: number } = {} 2 | let callCounts: { [name: string]: number } = {} 3 | 4 | let firstStartTime = 0 5 | 6 | let active = false 7 | export function activatePerformanceDebugging(activate: boolean): void { 8 | active = activate 9 | } 10 | export const performance: { 11 | now: (this: void) => number 12 | } = { 13 | now: Date.now, 14 | } 15 | /** 16 | * This is a little wierd, but we don't want to import performance from 'perf_hooks' directly, 17 | * because that will cause issues when this library is used in a browser. 18 | * Intended usage: 19 | * import { performance } from 'perf_hooks' 20 | * setPerformanceTimeFunction(performance.now) 21 | * @param now 22 | */ 23 | export function setPerformanceTimeFunction(now: () => number): void { 24 | performance.now = now 25 | } 26 | 27 | function noop(): void { 28 | // nothing 29 | } 30 | /** 31 | * Used to measure performance. 32 | * Starts a measurement, returns a function that should be called when the measurement is done. 33 | */ 34 | export function tic(id: string): () => void { 35 | if (!active) return noop 36 | if (!firstStartTime) firstStartTime = performance.now() 37 | 38 | if (!durations[id]) durations[id] = 0 39 | if (!callCounts[id]) callCounts[id] = 0 40 | const startTime = performance.now() 41 | 42 | return () => { 43 | const duration = performance.now() - startTime 44 | 45 | durations[id] = durations[id] + duration 46 | callCounts[id]++ 47 | } 48 | } 49 | 50 | export function ticTocPrint(): void { 51 | if (!active) return 52 | const totalDuration = performance.now() - firstStartTime 53 | 54 | const maxKeyLength = Math.max(...Object.keys(durations).map((k) => k.length)) 55 | 56 | console.log( 57 | 'ticTocPrint\n' + 58 | padStr(`Total duration `, maxKeyLength + 2) + 59 | `${Math.floor(totalDuration)}\n` + 60 | Object.entries(durations) 61 | .map((d) => { 62 | let str = padStr(`${d[0]} `, maxKeyLength + 2) 63 | 64 | str += padStr(`${Math.floor(d[1] * 10) / 10}`, 8) 65 | 66 | str += padStr(`${Math.floor((d[1] / totalDuration) * 1000) / 10}%`, 7) 67 | 68 | str += `${callCounts[d[0]]}` 69 | 70 | return str 71 | }) 72 | .join('\n') 73 | ) 74 | 75 | durations = {} 76 | callCounts = {} 77 | } 78 | 79 | function padStr(str: string, length: number): string { 80 | while (str.length < length) str += ' ' 81 | return str 82 | } 83 | -------------------------------------------------------------------------------- /src/resolver/lib/reference.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassReference, 3 | InstanceId, 4 | InstanceReference, 5 | LayerReference, 6 | ObjectReference, 7 | ParentReference, 8 | Reference, 9 | TimelineObjectInstance, 10 | } from '../../api' 11 | import { compareStrings } from './lib' 12 | import { tic } from './performance' 13 | 14 | /* 15 | * References are strings that are added to instances, 16 | * to indicate what objects, layers or classes they are derived from. 17 | */ 18 | 19 | export function isObjectReference(ref: Reference): ref is ObjectReference { 20 | return ref.startsWith('#') 21 | } 22 | export function getRefObjectId(ref: ObjectReference): string { 23 | return ref.slice(1) 24 | } 25 | export function isParentReference(ref: Reference): ref is ParentReference { 26 | return ref == '##parent' 27 | } 28 | 29 | export function isClassReference(ref: Reference): ref is ClassReference { 30 | return ref.startsWith('.') 31 | } 32 | export function getRefClass(ref: ClassReference): string { 33 | return ref.slice(1) 34 | } 35 | 36 | export function isLayerReference(ref: Reference): ref is LayerReference { 37 | return ref.startsWith('$') 38 | } 39 | export function getRefLayer(ref: LayerReference): string { 40 | return ref.slice(1) 41 | } 42 | 43 | export function isInstanceReference(ref: Reference): ref is InstanceReference { 44 | return ref.startsWith('@') 45 | } 46 | export function getRefInstanceId(ref: InstanceReference): InstanceId { 47 | return ref.slice(1) as InstanceId 48 | } 49 | 50 | /** Add / join references Arrays. Returns a sorted list of unique references */ 51 | export function joinReferences(references: Reference[], ...addReferences: Array): Reference[] { 52 | const toc = tic(' joinReferences') 53 | 54 | // Fast path: When nothing is added, return the original references: 55 | if (addReferences.length === 1 && typeof addReferences[0] !== 'string' && addReferences[0].length === 0) { 56 | return [...references] 57 | } 58 | 59 | let fastPath = false 60 | let resultingRefs: Reference[] = [] 61 | 62 | // Fast path: When a single ref is added 63 | if (addReferences.length === 1 && typeof addReferences[0] === 'string') { 64 | if (references.includes(addReferences[0])) { 65 | // The value already exists, return the original references: 66 | return [...references] 67 | } else { 68 | // just quickly add the reference and jump forward to sorting of resultingRefs: 69 | resultingRefs = [...references] 70 | resultingRefs.push(addReferences[0]) 71 | fastPath = true 72 | } 73 | } 74 | 75 | if (!fastPath) { 76 | const refSet = new Set() 77 | 78 | for (const ref of references) { 79 | if (!refSet.has(ref)) { 80 | refSet.add(ref) 81 | resultingRefs.push(ref) 82 | } 83 | } 84 | 85 | for (const addReference of addReferences) { 86 | if (typeof addReference === 'string') { 87 | if (!refSet.has(addReference)) { 88 | refSet.add(addReference) 89 | resultingRefs.push(addReference) 90 | } 91 | } else { 92 | for (const ref of addReference) { 93 | if (!refSet.has(ref)) { 94 | refSet.add(ref) 95 | resultingRefs.push(ref) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | resultingRefs.sort(compareStrings) 102 | toc() 103 | return resultingRefs 104 | } 105 | 106 | export function isReference(ref: ValueWithReference | TimelineObjectInstance[] | null): ref is ValueWithReference { 107 | return ref !== null && typeof (ref as any).value === 'number' 108 | } 109 | export interface ValueWithReference { 110 | value: number 111 | references: Reference[] 112 | } 113 | -------------------------------------------------------------------------------- /src/resolver/lib/timeline.ts: -------------------------------------------------------------------------------- 1 | import { TimelineObject } from '../../api' 2 | 3 | /** 4 | * Returns true if object has a layer. 5 | * Note: Objects without a layer are called "transparent objects", 6 | * and won't be present in the resolved state. 7 | */ 8 | export function objHasLayer(obj: TimelineObject): obj is TimelineObject & { layer: TimelineObject['layer'] } { 9 | return obj.layer !== undefined && obj.layer !== '' && obj.layer !== null 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig-examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sofie-automation/code-standard-preset/ts/tsconfig.lib", 3 | "include": [ 4 | "examples/**/*.ts" 5 | ], 6 | "exclude": [ 7 | "node_modules/**", 8 | "src/**/*.ts", 9 | "src/**/__tests__/*", 10 | "src/**/__mocks__/*" 11 | ], 12 | "compilerOptions": { 13 | "outDir": "./examples", 14 | "baseUrl": "./", 15 | "paths": { 16 | "*": [ 17 | "./node_modules/*" 18 | ], 19 | "superfly-timeline": [ 20 | "./src/index.ts" 21 | ] 22 | }, 23 | "types": [ 24 | "node" 25 | ], 26 | "sourceMap": false, 27 | "declaration": false, 28 | "declarationMap": false, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sofie-automation/code-standard-preset/ts/tsconfig.lib", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./", 8 | "paths": { 9 | "*": ["./node_modules/*"], 10 | "superfly-timeline": ["./src/index.ts"] 11 | }, 12 | "types": ["node"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "exclude": ["node_modules/**"], 4 | "compilerOptions": { 5 | "types": ["jest", "node"] 6 | }, 7 | "include": ["src/**/*.ts", "examples/**/*.ts", "scratch/**/*.ts"] 8 | } 9 | --------------------------------------------------------------------------------