├── .env.example ├── .github └── workflows │ └── prod.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── eslint.config.mjs ├── hls.js ├── README.md ├── adCreativeSignalingPlugin.js └── videoClicksPlugin.js ├── infra ├── .terraform.lock.hcl ├── backend.tf ├── data.tf ├── iam_role.tf ├── load_balancer.tf ├── main.tf ├── network.tf ├── security_groups.tf ├── tfvars │ ├── dev.tfvars │ └── prod.tfvars ├── users.tf └── variables.tf ├── package-lock.json ├── package.json ├── public ├── README.md ├── dash.js │ ├── dash.all.min.js │ └── dash.all.min.js.map ├── hls.js │ ├── adCreativeSignalingPlugin.js │ ├── hls.js │ ├── hls.js.map │ └── videoClicksPlugin.js ├── index.html ├── logo.svg ├── samples │ ├── asset-list-1 │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ └── video │ │ │ ├── 250kbit.m3u8 │ │ │ ├── 500kbit.m3u8 │ │ │ └── 800kbit.m3u8 │ ├── asset-list-2 │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ └── video │ │ │ ├── 250kbit.m3u8 │ │ │ ├── 500kbit.m3u8 │ │ │ └── 800kbit.m3u8 │ ├── dash-alt-mpd │ │ ├── index.html │ │ ├── live-alternative-ads.mpd │ │ └── vast-sample.xml │ ├── sample-ad-click │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ ├── vast-sample.xml │ │ └── video │ │ │ └── 800kbit.m3u8 │ ├── sample-ad-signaling │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ ├── vast-sample.xml │ │ └── video │ │ │ └── 800kbit.m3u8 │ ├── sample-api-jwt │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ ├── vast-sample.xml │ │ └── video │ │ │ ├── 250kbit.m3u8 │ │ │ ├── 500kbit.m3u8 │ │ │ └── 800kbit.m3u8 │ ├── sample-api-vastid-live │ │ ├── index.html │ │ └── vast-sample.xml │ ├── sample-api-vastid-live2 │ │ ├── index.html │ │ └── vast-sample.xml │ ├── sample-api-vastid-live3 │ │ ├── index.html │ │ └── vast-sample.xml │ ├── sample-api-vastid-live4 │ │ ├── index.html │ │ └── vast-sample.xml │ ├── sample-api-vastid │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ ├── vast-sample.xml │ │ └── video │ │ │ ├── 250kbit.m3u8 │ │ │ ├── 500kbit.m3u8 │ │ │ └── 800kbit.m3u8 │ ├── sample-api-vasturl │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ ├── vast-sample.xml │ │ └── video │ │ │ ├── 250kbit.m3u8 │ │ │ ├── 500kbit.m3u8 │ │ │ └── 800kbit.m3u8 │ ├── sample-edit-vast │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ ├── vast-sample.xml │ │ └── video │ │ │ └── 800kbit.m3u8 │ ├── sample-vast-1 │ │ ├── audio │ │ │ └── stereo │ │ │ │ ├── en │ │ │ │ └── 128kbit.m3u8 │ │ │ │ └── none │ │ │ │ └── 128kbit.m3u8 │ │ ├── index.html │ │ ├── main.m3u8 │ │ ├── vast-sample.xml │ │ └── video │ │ │ ├── 250kbit.m3u8 │ │ │ ├── 500kbit.m3u8 │ │ │ └── 800kbit.m3u8 │ ├── sample-vast-set │ │ ├── vast_01_15s.xml │ │ ├── vast_02_30s.xml │ │ ├── vast_03_30s.xml │ │ ├── vast_04_15s.xml │ │ ├── vast_05_15s.xml │ │ ├── vast_06-7_30s.xml │ │ ├── vast_08_15s.xml │ │ └── vast_09_15s.xml │ └── wse_api.js └── wse.png └── src ├── config └── vast-mapping.js ├── middlewares ├── params.js ├── vast-id.js └── whitelist.js ├── routes ├── api.js └── samples.js ├── server.js ├── trackingEvents └── tracking-events.js └── utils ├── jwt.js ├── list-mpd-generator.js ├── logger.js ├── replace-queryparams.js └── vast-parser.js /.env.example: -------------------------------------------------------------------------------- 1 | # Create a new secret key using: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" 2 | JWT_SECRET_KEY='251450bcd828e761eca58b28a4e15e9aa8fdc7e42579e8f9492303d0bbd10b21' 3 | JWT_BYPASS_VALIDATION='false' 4 | JWT_EXPIRES_IN='10y' 5 | API_DISABLE_SIGN='false' 6 | PINO_LOG_LEVEL='debug' 7 | PORT='3000' 8 | VAST_WHITELIST='www.vast1.com,www.vast2.com,localhost' 9 | ORIGIN_WHITELIST='' 10 | VAST_MAPPING_JSON={"1" : "http://localhost:3000/samples/sample-vast-1/vast-sample.xml","2" : "https://www.vast2.com","3" : "https://www.vast3.com"} -------------------------------------------------------------------------------- /.github/workflows/prod.yml: -------------------------------------------------------------------------------- 1 | name: "[PROD] Build and Deploy" 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | 7 | jobs: 8 | build: 9 | name: "Build and deploy" 10 | runs-on: ubuntu-latest 11 | environment: main 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Configure AWS Credentials 17 | uses: aws-actions/configure-aws-credentials@v4 18 | with: 19 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 20 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 21 | aws-region: us-east-1 22 | 23 | - name: Login to Amazon ECR 24 | id: login-ecr 25 | uses: aws-actions/amazon-ecr-login@v2 26 | 27 | - name: Build, tag, and push docker image to Amazon ECR 28 | id: build-image 29 | env: 30 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} 31 | ECR_REPOSITORY: vast2sgai-backend-prod 32 | IMAGE_TAG: latest 33 | run: | 34 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . 35 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG 36 | echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" 37 | 38 | - name: Download task definition 39 | run: | 40 | aws ecs describe-task-definition --task-definition vast2sgai-backend-prod --query taskDefinition > task-definition.json 41 | 42 | - name: Fill in the new image ID in the Amazon ECS task definition 43 | id: task-def 44 | uses: aws-actions/amazon-ecs-render-task-definition@v1 45 | with: 46 | task-definition: task-definition.json 47 | container-name: vast2sgai-backend-prod 48 | image: ${{ steps.build-image.outputs.image }} 49 | 50 | - name: Deploy Amazon ECS task definition 51 | id: ecs-deploy 52 | uses: aws-actions/amazon-ecs-deploy-task-definition@v2 53 | with: 54 | task-definition: ${{ steps.task-def.outputs.task-definition }} 55 | service: vast2sgai-backend-prod 56 | cluster: vast2sgai-prod 57 | wait-for-service-stability: true 58 | wait-for-minutes: 10 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # VSCode custom workspace settings 5 | .vscode/settings.json 6 | 7 | # Build output 8 | dist 9 | 10 | # npm 11 | node_modules/* 12 | 13 | .env 14 | 15 | infra/.terraform/* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a lightweight Node.js image 2 | FROM node:20 3 | 4 | # Install system dependencies for canvas 5 | RUN apt-get update && apt-get install -y \ 6 | libcairo2-dev \ 7 | libpango1.0-dev \ 8 | libjpeg-dev \ 9 | libgif-dev \ 10 | librsvg2-dev 11 | 12 | # Set working directory inside the container 13 | WORKDIR /usr/src/app 14 | 15 | # Copy only the package.json and package-lock.json to optimize caching 16 | COPY package*.json ./ 17 | 18 | # Install production dependencies only 19 | RUN npm install 20 | 21 | # Copy the rest of the application code 22 | COPY . . 23 | 24 | # Expose the application's port 25 | EXPOSE 3000 26 | 27 | # Start the application 28 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VAST-2-SGAI 2 | 3 | ## Overview 4 | 5 | ### Introduction 6 | This is a project developed in the context of the **Montevideo TECH Summer Camp 2025**! During the Summer Camp, this project aims to create value for the video community while participants learn and share knowledge, fostering collaborative growth and making a positive impact together on the video community. 7 | 8 | ### Project Goal 9 | This project aims to create an open-source tool that converts VAST XML files into HLS interstitial AssetLists, supporting Server-Guided Ad Insertion (SGAI) without requiring Server-Side Ad Insertion (SSAI) providers. 10 | 11 | ### Project Duration 12 | **Start Date:** November 11 13 | **End Date:** January 31 14 | 15 | 16 | ## How to run 17 | 1. Start the project 18 | 19 | With Node: 20 | ``` 21 | npm install 22 | npm run dev 23 | ``` 24 | or Docker Compose 25 | ``` 26 | docker compose up 27 | ``` 28 | 2. Open a browser in `http://localhost:3000` to try the examples 29 | 30 | ## Communication Channels 31 | 32 | - **[video-dev.org](https://video-dev.org)**: Join us on the `#montevideo-summerprojects` channel to discuss the project, share updates, and collaborate. 33 | - **GitHub Repository**: Follow and contribute at [montevideo-tech/vast-2-sgai](https://github.com/montevideo-tech/vast-2-sgai). 34 | 35 | ## Methodology 36 | 37 | We follow a collaborative, issue-driven development approach with regular meetings to discuss ongoing work and new developments. 38 | 39 | ### Issues 40 | All topics related to development and design are logged and tracked via GitHub Issues at [Issues Page](https://github.com/montevideo-tech/vast-2-sgai/issues). 41 | 42 | 1. **Creating Issues**: Participants can create and report issues covering development topics, feature requests, bugs, or any other relevant discussion points. 43 | 2. **Tagging for Discussion**: Issues tagged with `next meeting` will be discussed in the upcoming meeting. Participants should ideally review and comment on these issues beforehand. 44 | 45 | ### Weekly Meetings 46 | 47 | _meetigns MAY vary, please follow the discussions in [video-dev.org](https://video-dev.org) `#montevideo-summerprojects` channel_ 48 | 49 | - **Frequency**: Every Thursday 50 | - **Time**: 9 a.m. PST (12 p.m. EST) 51 | - **Link**: https://meet.google.com/crh-zrtc-guz 52 | 53 | During each meeting: 54 | 1. **Discussion on Tagged Issues**: We’ll focus on issues tagged `next meeting`, aiming to resolve roadblocks and outline actionable steps. 55 | 2. **Preparation**: We encourage each participant to review and comment on tagged issues ahead of time, ensuring an efficient and informed discussion. 56 | 57 | ## Documentation 58 | 59 | **Digital Whiteboard**: A visual tool used during meetings to support discussions can be accessed [here](https://drive.google.com/file/d/1MPodWl1R3DhgWXG54HC3dLo7qD7aTzZT/view?usp=sharing). 60 | 61 | --- 62 | 63 | Feel free to reach out via GitHub or on [video-dev.org](https://video-dev.org) `#montevideo-summerprojects` to get started, contribute, or ask questions. Let's make this tool a valuable resource for the video community! 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | vast-2-sgai: 5 | build: . 6 | working_dir: /usr/src/app 7 | volumes: 8 | - .:/usr/src/app # Mount the current directory to the container 9 | - /usr/src/app/node_modules # Avoid mounting the node_modules locally 10 | ports: 11 | - "3000:3000" 12 | command: > 13 | sh -c "npm install && npm run dev" 14 | environment: 15 | NODE_ENV: development 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | files: ["src/**/*.js"], 4 | languageOptions: { 5 | ecmaVersion: 2021, 6 | sourceType: "module", 7 | globals: { 8 | process: "readonly", 9 | __dirname: "readonly", 10 | require: "readonly", 11 | module: "readonly", 12 | exports: "readonly" 13 | } 14 | }, 15 | rules: { 16 | "semi": ["error", "always"], 17 | "quotes": ["error", "double"], 18 | "indent": ["warn", 2], 19 | "no-unused-vars": ["warn"], 20 | "no-console": "off" 21 | } 22 | } 23 | ]; 24 | -------------------------------------------------------------------------------- /hls.js/README.md: -------------------------------------------------------------------------------- 1 | # Ad Creative Signaling Plugin 2 | 3 | The **Ad Creative Signaling Plugin** is designed to read and execute tracking events embedded in the `X-AD-CREATIVE-SIGNALING` tag, as specified in the `X-ASSET-LIST`. 4 | 5 | ## Features 6 | - Automatically handles tracking events for creative signals. 7 | - Seamlessly integrates with the `hls` global object. 8 | 9 | ## Getting Started 10 | 11 | ### Prerequisites 12 | Ensure you have the `hls` object initialized in your project before using this plugin. 13 | 14 | ### Installation 15 | Include the script for the Ad Creative Signaling Plugin in your project. 16 | 17 | ```html 18 | 19 | ``` 20 | 21 | ### Usage 22 | To initialize the plugin, simply pass the global hls object to the AdSignalingManager. 23 | 24 | ```html 25 | 28 | ``` 29 | 30 | ### How It Works 31 | Once initialized, the plugin automatically processes the requests defined for each creative signal, ensuring that tracking events are executed as specified in the X-AD-CREATIVE-SIGNALING tag. -------------------------------------------------------------------------------- /hls.js/adCreativeSignalingPlugin.js: -------------------------------------------------------------------------------- 1 | const SUPPORTED_TRACKING_EVENTS = ["start", "firstQuartile", "midpoint", "thirdQuartile", "complete", "progress", "impression"]; 2 | 3 | class AdSignalingManager { 4 | constructor(hls) { 5 | console.log("Initializing AdSignalingManager"); 6 | this.hls = hls; 7 | this.interstitialsController = hls.interstitialsController; 8 | this.trackingEventsQueue = []; 9 | 10 | hls.on(Hls.Events.INTERSTITIAL_ASSET_STARTED, async (_, data) => { 11 | this.assetPlayer = data.player; 12 | 13 | this.assetPlayer.hls.on(Hls.Events.MEDIA_ENDED, this.checkAdCreativeSignaling); 14 | this.signalInterval = self.setInterval(this.checkAdCreativeSignaling, 500); 15 | 16 | const { assetListIndex } = data; 17 | const assetListResponse = data.event.assetListResponse; 18 | const asset = assetListResponse.ASSETS?.[assetListIndex]; 19 | const creativeSignaling = asset?.["X-AD-CREATIVE-SIGNALING"]; 20 | const trackingEvents = creativeSignaling?.payload?.[0]?.tracking ?? []; 21 | this.trackingEventsQueue = trackingEvents; 22 | }); 23 | 24 | hls.on(Hls.Events.INTERSTITIAL_ASSET_ENDED, async (_, data) => { 25 | this.cleanup(); 26 | }); 27 | } 28 | 29 | cleanup = () => { 30 | console.log(this.trackingEventsQueue) 31 | this.trackingEventsQueue = [] 32 | this.assetPlayer = null; 33 | clearInterval(this.signalInterval); 34 | }; 35 | 36 | checkAdCreativeSignaling = () => { 37 | if (!this.trackingEventsQueue) return; 38 | 39 | const currentTime = this.assetPlayer.currentTime; 40 | 41 | this.trackingEventsQueue.forEach((event, index) => { 42 | if (SUPPORTED_TRACKING_EVENTS.includes(event.type)) { 43 | const tolerance = 0.75; // time difference tolerance in seconds 44 | if ( 45 | (event?.offset === undefined || (Math.abs(currentTime - event.offset) <= tolerance)) 46 | ) { 47 | Promise.all(event.urls.map((url) => this.sendTrackingEvent(url))); 48 | this.trackingEventsQueue.splice(index, 1); 49 | } 50 | } 51 | }); 52 | }; 53 | 54 | sendTrackingEvent = async (url) => { 55 | try { 56 | const response = await fetch(url); 57 | if (!response.ok) { 58 | this.hls?.logger.error( 59 | `Error on Ad Creative Signaling event tracking request ${url}: ${response.status}` 60 | ); 61 | } 62 | } catch (error) { 63 | this.hls?.logger.error( 64 | `Error on Ad Creative Signaling event tracking request ${url}:`, 65 | error 66 | ); 67 | } 68 | }; 69 | 70 | destroy() { 71 | this.cleanup(); 72 | this.hls = this.interstitialsController = null; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /hls.js/videoClicksPlugin.js: -------------------------------------------------------------------------------- 1 | class VideoClicksPlugin { 2 | constructor(hls, options) { 3 | if (!hls || typeof hls.on !== "function") { 4 | throw new Error("Invalid HLS.js instance passed to the plugin."); 5 | } 6 | 7 | this.hls = hls; 8 | this.options = options || {}; 9 | this.container = options.container || document.body; 10 | this.clickEventUrl = options.clickEventUrl || null; 11 | this.assetList = { ASSETS: [] }; 12 | this.currentAdIndex = 0; 13 | this.isAdPlaying = false; 14 | this.adVignette = null; 15 | 16 | this.container.style.position = "relative"; 17 | 18 | this.init(); 19 | } 20 | 21 | init() { 22 | this.hls.on(Hls.Events.INTERSTITIAL_ASSET_STARTED, (_, data) => { 23 | const { assetListIndex } = data; 24 | const assetListResponse = data.event?.assetListResponse; 25 | if (!assetListResponse || !assetListResponse.ASSETS?.length) { 26 | return; 27 | } 28 | 29 | this.assetList = assetListResponse; 30 | this.currentAdIndex = assetListIndex; 31 | 32 | this.loadAdAssets(); 33 | }); 34 | 35 | this.hls.on(Hls.Events.INTERSTITIAL_ASSET_ENDED, () => { 36 | if (this.adVignette && this.container.contains(this.adVignette)) { 37 | this.container.removeChild(this.adVignette); 38 | } 39 | this.isAdPlaying = false; 40 | }); 41 | 42 | this.hls.on(Hls.Events.INTERSTITIAL_ENDED, () => { 43 | this.cleanupAd(); 44 | }); 45 | } 46 | 47 | loadAdAssets() { 48 | if (this.assetList.ASSETS.length === 0) { 49 | return; 50 | } 51 | 52 | this.showAd(this.assetList.ASSETS[this.currentAdIndex]); 53 | } 54 | 55 | showAd(ad) { 56 | if (this.isAdPlaying) return; 57 | 58 | const { DURATION, "X-VAST2SGAI-VIDEOCLICKS": videoClicks } = ad; 59 | 60 | if (!videoClicks || !videoClicks.clickThrough?.url) { 61 | this.loadNextAd(); 62 | return; 63 | } 64 | 65 | this.isAdPlaying = true; 66 | this.adVignette = this.createAdOverlay(this.container, videoClicks); 67 | this.container.appendChild(this.adVignette); 68 | 69 | setTimeout(() => { 70 | if (this.container.contains(this.adVignette)) { 71 | this.container.removeChild(this.adVignette); 72 | } 73 | this.loadNextAd(); 74 | }, DURATION * 1000); 75 | } 76 | 77 | createAdOverlay(videoContainer, videoClicks) { 78 | const clickThroughUrl = videoClicks.clickThrough.url; 79 | const clickTracking = videoClicks.clickTracking || []; 80 | const videoElement = videoContainer.querySelector("video"); 81 | if (!videoElement) { 82 | return; 83 | } 84 | 85 | videoContainer.style.position = "relative"; 86 | 87 | const adVignette = document.createElement("div"); 88 | adVignette.style.position = "absolute"; 89 | adVignette.style.backgroundColor = "rgba(0, 0, 0, 0.7)"; 90 | adVignette.style.color = "#fff"; 91 | adVignette.style.borderRadius = "12px"; 92 | adVignette.style.padding = "8px 12px"; 93 | adVignette.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.4)"; 94 | adVignette.style.fontFamily = "Arial, sans-serif"; 95 | adVignette.style.fontSize = "12px"; 96 | adVignette.style.zIndex = "10"; 97 | adVignette.style.cursor = "pointer"; 98 | adVignette.style.pointerEvents = "auto"; 99 | adVignette.style.display = "inline-flex"; 100 | adVignette.style.alignItems = "center"; 101 | adVignette.style.minWidth = "150px"; 102 | adVignette.style.justifyContent = "space-between"; 103 | 104 | const updateVignettePosition = () => { 105 | const videoRect = videoElement.getBoundingClientRect(); 106 | adVignette.style.top = "10px"; 107 | adVignette.style.left = `${videoRect.width - 200}px`; 108 | }; 109 | 110 | updateVignettePosition(); 111 | window.addEventListener("resize", updateVignettePosition); 112 | 113 | adVignette.onclick = () => { 114 | this.sendAdClickEvent(clickTracking); 115 | window.open(clickThroughUrl, "_blank"); 116 | }; 117 | 118 | const adText = document.createElement("span"); 119 | adText.textContent = new URL(clickThroughUrl).hostname + "..."; 120 | adText.style.marginRight = "8px"; 121 | 122 | const closeButton = document.createElement("button"); 123 | closeButton.textContent = "✖"; 124 | closeButton.style.backgroundColor = "transparent"; 125 | closeButton.style.border = "none"; 126 | closeButton.style.color = "#fff"; 127 | closeButton.style.fontSize = "12px"; 128 | closeButton.style.cursor = "pointer"; 129 | closeButton.style.marginLeft = "5px"; 130 | closeButton.onclick = (event) => { 131 | event.stopPropagation(); 132 | adVignette.style.opacity = "0"; 133 | setTimeout(() => adVignette.remove(), 300); 134 | window.removeEventListener("resize", updateVignettePosition); 135 | }; 136 | 137 | adVignette.appendChild(adText); 138 | adVignette.appendChild(closeButton); 139 | videoContainer.appendChild(adVignette); 140 | 141 | return adVignette; 142 | } 143 | 144 | cleanupAd() { 145 | const overlays = this.container.querySelectorAll("div"); 146 | overlays.forEach((overlay) => { 147 | if (overlay.onclick) { 148 | this.container.removeChild(overlay); 149 | } 150 | }); 151 | } 152 | 153 | loadNextAd() { 154 | this.currentAdIndex++; 155 | if (this.currentAdIndex < this.assetList.ASSETS.length) { 156 | this.showAd(this.assetList.ASSETS[this.currentAdIndex]); 157 | } else { 158 | this.isAdPlaying = false; 159 | } 160 | } 161 | 162 | sendAdClickEvent(clickTracking) { 163 | clickTracking.forEach((tracking) => { 164 | fetch(tracking.url, { method: "GET" }).catch(() => {}); 165 | }); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /infra/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "4.55.0" 6 | constraints = ">= 3.73.0, 4.55.0" 7 | hashes = [ 8 | "h1:vSVjfh4GIrca2Z3YPjWMaac5hOEBc1U3xOOwFmo7HZc=", 9 | "zh:0866f25575bad3b9c313cd778c94fc65e79d335af2d20a3480f79d7731d93b7b", 10 | "zh:2c05c16155cbc054622cf83e4b6614fef35935b00b238e4c21ee225e6c896770", 11 | "zh:2efba66649fb12af0492c6cce4e2361fe9139df648734264f61a9a1ef754df53", 12 | "zh:3c60bb53e3b65d7f86699fae0797a55a9aa41b8ba377aaff4daf23d1661393a9", 13 | "zh:41f6dcd90b54b623d523df8fb4a30779cfe22e9ab59516bc05b29291a7af0946", 14 | "zh:4b8330b154e9e2d035dd5488abcac25efec1fa6055d3a70894a8c0384f0579d6", 15 | "zh:595f263706cf1fb6b8447e2ec343638de4360841a15e6bff6ccbb0ff86c7ce74", 16 | "zh:5dfc5b858a43cf45fde5542eb673f6104c14cdc3d73843d1b87a9e44545cbad4", 17 | "zh:7bbe05cf30521f0110603bb84995a4025ce7810626010276600e4b402143df27", 18 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 19 | "zh:a490e68c63504d3301d6dcb700c95778d93bb2baa6632a46c5a1d62862a7067c", 20 | "zh:c4f9f6659148528375c8a822163925c9aae490ccce2e6301cefbbab009531971", 21 | "zh:ef66070f957408f1c924ddfd5dbd0d34bce16efd9e36ccecbf699de72beb131f", 22 | "zh:f7ba5e3e62a2b51b24e326797a89fdd86bafaea7d1912738d514c9903c14d7f2", 23 | "zh:ffc20b7d9f7bd331fb6451d0fc92c68196383d7115e69380de6566cc268cb9b9", 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /infra/backend.tf: -------------------------------------------------------------------------------- 1 | ## ECS 2 | ### Cluster 3 | resource "aws_ecs_cluster" "microservices" { 4 | name = "${local.app_name}-${terraform.workspace}" 5 | setting { 6 | name = "containerInsights" 7 | value = "enabled" 8 | } 9 | } 10 | 11 | locals { 12 | port = 3000 13 | } 14 | 15 | resource "aws_ecr_repository" "backend" { 16 | name = "${local.app_name}-backend-${terraform.workspace}" 17 | image_tag_mutability = "MUTABLE" 18 | 19 | image_scanning_configuration { 20 | scan_on_push = true 21 | } 22 | } 23 | 24 | resource "aws_lb_target_group" "backend" { 25 | name = "${local.app_name}-backend-${terraform.workspace}" 26 | port = local.port 27 | protocol = "HTTP" 28 | target_type = "ip" 29 | deregistration_delay = 60 30 | health_check { 31 | path = "/" 32 | } 33 | vpc_id = module.vpc.vpc_id 34 | depends_on = [ 35 | aws_lb.microservices_alb 36 | ] 37 | } 38 | 39 | resource "aws_cloudwatch_log_group" "backend" { 40 | name = "${local.app_name}-backend-${terraform.workspace}" 41 | retention_in_days = 1 42 | } 43 | 44 | resource "aws_ecs_task_definition" "backend" { 45 | family = "${local.app_name}-backend-${terraform.workspace}" 46 | requires_compatibilities = ["FARGATE"] 47 | network_mode = "awsvpc" 48 | cpu = 512 49 | memory = 1024 50 | execution_role_arn = aws_iam_role.ECSTaskExecutionRole.arn 51 | task_role_arn = aws_iam_role.ECSTaskRole.arn 52 | container_definitions = <[!info] 49 | The datetimes specified in both fields are relative to each other, and the player does not take the current timezone into account. To schedule an interstitial 5 seconds after the main content starts, simply set the second datetime to be 5 seconds later than the first, as shown in the example below. 50 | 51 | >[!warning] 52 | > Both `#EXT-X-PROGRAM-DATE-TIME` and `#EXT-X-DATERANGE` have to be defined in the playlist, not in the main HLS manifest. 53 | 54 | 55 | Result: ✅ Worked as expected. 56 | 57 | ``` m3u8 58 | #EXTM3U 59 | #EXT-X-MEDIA-SEQUENCE:0 60 | #EXT-X-TARGETDURATION:2 61 | #EXT-X-PROGRAM-DATE-TIME:2024-11-13T13:55:00.000Z 62 | #EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2024-11-13T13:55:05.000Z",DURATION=45.000,X-ASSET-LIST="http://localhost:5500/assets/ads/ads.json",X-RESUME-OFFSET=0,X-RESTRICT="SKIP,JUMP" 63 | 64 | #EXTINF:2, 65 | https://bitdash-a.akamaihd.net/content/sintel/hls/video/800kbit/seq-0.ts 66 | #EXTINF:2, 67 | https://bitdash-a.akamaihd.net/content/sintel/hls/video/800kbit/seq-1.ts 68 | #EXTINF:2, 69 | https://bitdash-a.akamaihd.net/content/sintel/hls/video/800kbit/seq-2.ts 70 | #EXTINF:2, 71 | https://bitdash-a.akamaihd.net/content/sintel/hls/video/800kbit/seq-3.ts 72 | #EXTINF:2, 73 | . 74 | . 75 | . 76 | #EXT-X-ENDLIST 77 | ``` 78 | 79 | ## Use #EXT-X-DEFINE:QUERYPARAM 80 | Added #EXT-X-DEFINE:QUERYPARAM to the Multivariant Playlist and the Playlists, and it worked as expected. Using this approach, we can send any query parameter we want to the VAST-2-SGAI 81 | 82 | 83 | Result: ✅ Worked as expected. 84 | 85 | ``` main.m3u8 86 | #EXTM3U 87 | #EXT-X-DEFINE:QUERYPARAM="user" 88 | #EXT-X-DEFINE:QUERYPARAM="content" 89 | 90 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 91 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 92 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 93 | 94 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 95 | video/800kbit.m3u8?user={$user}&content={$content} 96 | 97 | #EXT-X-ENDLIST 98 | ``` 99 | 100 | ``` 800kbit.m3u8 101 | #EXTM3U 102 | #EXT-X-MEDIA-SEQUENCE:0 103 | #EXT-X-TARGETDURATION:2 104 | #EXT-X-DEFINE:QUERYPARAM="user" 105 | #EXT-X-DEFINE:QUERYPARAM="content" 106 | 107 | #EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2024-11-13T13:55:05.000Z",DURATION=45.000,X-ASSET-LIST="/assets/ads/ads.json?user={$user}&content={$content}",X-RESUME-OFFSET=0,X-RESTRICT="SKIP,JUMP" 108 | 109 | #EXT-X-PROGRAM-DATE-TIME:2024-11-13T13:55:00.000Z 110 | ... 111 | 112 | ``` -------------------------------------------------------------------------------- /public/hls.js/adCreativeSignalingPlugin.js: -------------------------------------------------------------------------------- 1 | const SUPPORTED_TRACKING_EVENTS = ["start", "firstQuartile", "midpoint", "thirdQuartile", "complete", "progress", "impression"]; 2 | 3 | class AdSignalingManager { 4 | constructor(hls) { 5 | console.log("Initializing AdSignalingManager"); 6 | this.hls = hls; 7 | this.interstitialsController = hls.interstitialsController; 8 | this.trackingEventsQueue = []; 9 | 10 | hls.on(Hls.Events.INTERSTITIAL_ASSET_STARTED, async (_, data) => { 11 | this.assetPlayer = data.player; 12 | 13 | this.assetPlayer.hls.on(Hls.Events.MEDIA_ENDED, this.checkAdCreativeSignaling); 14 | this.signalInterval = self.setInterval(this.checkAdCreativeSignaling, 500); 15 | 16 | const { assetListIndex } = data; 17 | const assetListResponse = data.event.assetListResponse; 18 | const asset = assetListResponse.ASSETS?.[assetListIndex]; 19 | const creativeSignaling = asset?.["X-AD-CREATIVE-SIGNALING"]; 20 | const trackingEvents = creativeSignaling?.payload?.[0]?.tracking ?? []; 21 | this.trackingEventsQueue = [...trackingEvents]; 22 | }); 23 | 24 | hls.on(Hls.Events.INTERSTITIAL_ASSET_ENDED, async (_, data) => { 25 | this.cleanup(); 26 | }); 27 | } 28 | 29 | cleanup = () => { 30 | console.log(this.trackingEventsQueue) 31 | this.trackingEventsQueue = [] 32 | this.assetPlayer = null; 33 | clearInterval(this.signalInterval); 34 | }; 35 | 36 | checkAdCreativeSignaling = () => { 37 | if (!this.trackingEventsQueue) return; 38 | 39 | const currentTime = this.assetPlayer.currentTime; 40 | 41 | this.trackingEventsQueue.forEach((event, index) => { 42 | if (SUPPORTED_TRACKING_EVENTS.includes(event.type)) { 43 | const tolerance = 0.75; // time difference tolerance in seconds 44 | if ( 45 | (event?.offset === undefined || (Math.abs(currentTime - event.offset) <= tolerance)) 46 | ) { 47 | Promise.all(event.urls.map((url) => this.sendTrackingEvent(url))); 48 | this.trackingEventsQueue.splice(index, 1); 49 | } 50 | } 51 | }); 52 | }; 53 | 54 | sendTrackingEvent = async (url) => { 55 | try { 56 | const response = await fetch(url); 57 | if (!response.ok) { 58 | this.hls?.logger.error( 59 | `Error on Ad Creative Signaling event tracking request ${url}: ${response.status}` 60 | ); 61 | } 62 | } catch (error) { 63 | this.hls?.logger.error( 64 | `Error on Ad Creative Signaling event tracking request ${url}:`, 65 | error 66 | ); 67 | } 68 | }; 69 | 70 | destroy() { 71 | this.cleanup(); 72 | this.hls = this.interstitialsController = null; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /public/hls.js/videoClicksPlugin.js: -------------------------------------------------------------------------------- 1 | class VideoClicksPlugin { 2 | constructor(hls, options) { 3 | if (!hls || typeof hls.on !== "function") { 4 | throw new Error("Invalid HLS.js instance passed to the plugin."); 5 | } 6 | 7 | this.hls = hls; 8 | this.options = options || {}; 9 | this.container = options.container || document.body; 10 | this.clickEventUrl = options.clickEventUrl || null; 11 | this.assetList = { ASSETS: [] }; 12 | this.currentAdIndex = 0; 13 | this.isAdPlaying = false; 14 | this.adVignette = null; 15 | 16 | this.container.style.position = "relative"; 17 | 18 | this.init(); 19 | } 20 | 21 | init() { 22 | this.hls.on(Hls.Events.INTERSTITIAL_ASSET_STARTED, (_, data) => { 23 | const { assetListIndex } = data; 24 | const assetListResponse = data.event?.assetListResponse; 25 | if (!assetListResponse || !assetListResponse.ASSETS?.length) { 26 | return; 27 | } 28 | 29 | this.assetList = assetListResponse; 30 | this.currentAdIndex = assetListIndex; 31 | 32 | this.loadAdAssets(); 33 | }); 34 | 35 | this.hls.on(Hls.Events.INTERSTITIAL_ASSET_ENDED, () => { 36 | if (this.adVignette && this.container.contains(this.adVignette)) { 37 | this.container.removeChild(this.adVignette); 38 | } 39 | this.isAdPlaying = false; 40 | }); 41 | 42 | this.hls.on(Hls.Events.INTERSTITIAL_ENDED, () => { 43 | this.cleanupAd(); 44 | }); 45 | } 46 | 47 | loadAdAssets() { 48 | if (this.assetList.ASSETS.length === 0) { 49 | return; 50 | } 51 | 52 | this.showAd(this.assetList.ASSETS[this.currentAdIndex]); 53 | } 54 | 55 | showAd(ad) { 56 | if (this.isAdPlaying) return; 57 | 58 | const { DURATION, "X-VAST2SGAI-VIDEOCLICKS": videoClicks } = ad; 59 | 60 | if (!videoClicks || !videoClicks.clickThrough?.url) { 61 | this.loadNextAd(); 62 | return; 63 | } 64 | 65 | this.isAdPlaying = true; 66 | this.adVignette = this.createAdOverlay(this.container, videoClicks); 67 | this.container.appendChild(this.adVignette); 68 | 69 | setTimeout(() => { 70 | if (this.container.contains(this.adVignette)) { 71 | this.container.removeChild(this.adVignette); 72 | } 73 | this.loadNextAd(); 74 | }, DURATION * 1000); 75 | } 76 | 77 | createAdOverlay(videoContainer, videoClicks) { 78 | const clickThroughUrl = videoClicks.clickThrough.url; 79 | const clickTracking = videoClicks.clickTracking || []; 80 | const videoElement = videoContainer.querySelector("video"); 81 | if (!videoElement) { 82 | return; 83 | } 84 | 85 | videoContainer.style.position = "relative"; 86 | 87 | const adVignette = document.createElement("div"); 88 | adVignette.style.position = "absolute"; 89 | adVignette.style.backgroundColor = "rgba(0, 0, 0, 0.7)"; 90 | adVignette.style.color = "#fff"; 91 | adVignette.style.borderRadius = "12px"; 92 | adVignette.style.padding = "8px 12px"; 93 | adVignette.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.4)"; 94 | adVignette.style.fontFamily = "Arial, sans-serif"; 95 | adVignette.style.fontSize = "12px"; 96 | adVignette.style.zIndex = "10"; 97 | adVignette.style.cursor = "pointer"; 98 | adVignette.style.pointerEvents = "auto"; 99 | adVignette.style.display = "inline-flex"; 100 | adVignette.style.alignItems = "center"; 101 | adVignette.style.minWidth = "150px"; 102 | adVignette.style.justifyContent = "space-between"; 103 | 104 | const updateVignettePosition = () => { 105 | const videoRect = videoElement.getBoundingClientRect(); 106 | adVignette.style.top = "10px"; 107 | adVignette.style.left = `${videoRect.width - 200}px`; 108 | }; 109 | 110 | updateVignettePosition(); 111 | window.addEventListener("resize", updateVignettePosition); 112 | 113 | adVignette.onclick = () => { 114 | this.sendAdClickEvent(clickTracking); 115 | window.open(clickThroughUrl, "_blank"); 116 | }; 117 | 118 | const adText = document.createElement("span"); 119 | adText.textContent = new URL(clickThroughUrl).hostname + "..."; 120 | adText.style.marginRight = "8px"; 121 | 122 | const closeButton = document.createElement("button"); 123 | closeButton.textContent = "✖"; 124 | closeButton.style.backgroundColor = "transparent"; 125 | closeButton.style.border = "none"; 126 | closeButton.style.color = "#fff"; 127 | closeButton.style.fontSize = "12px"; 128 | closeButton.style.cursor = "pointer"; 129 | closeButton.style.marginLeft = "5px"; 130 | closeButton.onclick = (event) => { 131 | event.stopPropagation(); 132 | adVignette.style.opacity = "0"; 133 | setTimeout(() => adVignette.remove(), 300); 134 | window.removeEventListener("resize", updateVignettePosition); 135 | }; 136 | 137 | adVignette.appendChild(adText); 138 | adVignette.appendChild(closeButton); 139 | videoContainer.appendChild(adVignette); 140 | 141 | return adVignette; 142 | } 143 | 144 | cleanupAd() { 145 | const overlays = this.container.querySelectorAll("div"); 146 | overlays.forEach((overlay) => { 147 | if (overlay.onclick) { 148 | this.container.removeChild(overlay); 149 | } 150 | }); 151 | } 152 | 153 | loadNextAd() { 154 | this.currentAdIndex++; 155 | if (this.currentAdIndex < this.assetList.ASSETS.length) { 156 | this.showAd(this.assetList.ASSETS[this.currentAdIndex]); 157 | } else { 158 | this.isAdPlaying = false; 159 | } 160 | } 161 | 162 | sendAdClickEvent(clickTracking) { 163 | clickTracking.forEach((tracking) => { 164 | fetch(tracking.url, { method: "GET" }).catch(() => {}); 165 | }); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | VAST-2-SGAI 3 | 56 | 57 | 58 | 59 |
60 |
61 | 68 |

VAST-2-SGAI

69 |
70 |
71 |
72 |

Demo Showcase

73 | 119 |
120 | 121 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/asset-list-1/index.html: -------------------------------------------------------------------------------- 1 | 2 | Asset List 1 3 | 4 | 67 | 68 | 69 | 70 |
71 |
72 | 73 | 80 | 81 |

VAST-2-SGAI

82 |
83 |
84 |
85 |

Asset List 1

86 |

HLS, interstitial@5sec, 1 ad

87 | 88 |
89 | 90 |
91 |
92 | 93 | 166 | 167 | -------------------------------------------------------------------------------- /public/samples/asset-list-1/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/asset-list-2/index.html: -------------------------------------------------------------------------------- 1 | 2 | Asset List 2 3 | 4 | 67 | 68 | 69 | 70 |
71 |
72 | 73 | 80 | 81 |

VAST-2-SGAI

82 |
83 |
84 |
85 |

Asset List 2

86 |

HLS, interstitial@5sec, 2 ads in Ad POD

87 | 88 |
89 | 90 |
91 |
92 | 93 | 166 | 167 | -------------------------------------------------------------------------------- /public/samples/asset-list-2/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/dash-alt-mpd/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API 1 3 | 4 | 67 | 68 | 69 | 70 |
71 |
72 | 73 | 80 | 81 |

VAST-2-SGAI

82 |
83 |
84 |
85 |

Sample dash.js AlternativeMPD with ListMPD + CMCD v2

86 |

87 | DASH + AlternativeMPD + ListMPD + CMCD v2, 1 ad parsed form 88 | this VAST example 89 |

90 |

91 | API Call: 92 | try out 96 |

97 | 98 |
99 | 100 |
101 |
102 | 103 | 205 | 206 | -------------------------------------------------------------------------------- /public/samples/dash-alt-mpd/live-alternative-ads.mpd: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://livesim.dashif.org/livesim2/scte35_2/testpic_2s/ 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/samples/sample-ad-click/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample VAST 1 3 | 4 | 5 | 88 | 89 | 90 | 91 | 92 |
93 |
94 | 95 | 102 | 103 |

VAST-2-SGAI

104 |
105 |
106 |
107 |

Sample VAST with Click Tracking

108 |

109 | HLS, interstitial@5sec, 1 ad parsed form 110 | this Google IMA VAST example 111 |

112 |
113 | 114 |
115 | 190 | -------------------------------------------------------------------------------- /public/samples/sample-ad-click/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/sample-ad-signaling/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample VAST 1 3 | 4 | 5 | 68 | 69 | 70 | 71 |
72 |
73 | 74 | 81 | 82 |

VAST-2-SGAI

83 |
84 |
85 |
86 |

Sample VAST with Tracking Events

87 |

88 | HLS, interstitial@5sec, 1 ad parsed form 89 | this Google IMA VAST example 90 |

91 |
92 | 93 |
94 |
95 | 96 | 171 | 172 | -------------------------------------------------------------------------------- /public/samples/sample-ad-signaling/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/sample-api-jwt/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API 1 3 | 4 | 71 | 72 | 73 | 74 |
75 |
76 | 77 | 84 | 85 |

VAST-2-SGAI

86 |
87 |
88 |
89 |

Sample API JWT

90 |

91 | HLS, interstitial@5sec, 1 ad parsed form 92 | this Google IMA VAST example 93 |

94 |

95 | JWT Secret: 96 | 251450bcd828e761eca58b28a4e15e9aa8fdc7e42579e8f9492303d0bbd10b21 97 |

98 |

99 | JWT: 100 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAvc2FtcGxlcy9zYW1wbGUtdmFzdC0xL3Zhc3Qtc2FtcGxlLnhtbCIsImlhdCI6MTczMzc3MzcxMiwiZXhwIjoyMDQ5MzQ5NzEyfQ.iliLPo-KgB4mCH6mLTrtGUtDy28dN_zZYDWV_xwU5sA 101 |

102 |

103 | Use jwt.io to decode the JWT 104 |

105 |

106 | API Call: 107 | try out 111 |

112 | 113 |
114 | 115 |
116 |
117 | 118 | 191 | 192 | -------------------------------------------------------------------------------- /public/samples/sample-api-jwt/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/sample-api-vastid-live/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API VASTID (LIVE) 3 | 4 | 5 | 6 | 69 | 70 | 71 | 72 |
73 |
74 | 75 | 82 | 83 |

VAST-2-SGAI

84 |
85 |
86 |
87 | 88 |

Sample API VASTID (LIVE w/BBB)

89 |

90 | HLS, live, 1 ad parsed form 91 | this Google IMA VAST example 92 |

93 |

94 | Source: BBB restreamed as live 95 |

96 | 97 | 98 | 99 |
100 | API Event:
...

101 | Current Time:

102 | Program Date Time:
...

103 | Interstitial:
...


104 |
105 | 106 |
107 |
108 | 187 | 188 | -------------------------------------------------------------------------------- /public/samples/sample-api-vastid-live2/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API 1 3 | 4 | 5 | 6 | 69 | 70 | 71 | 72 |
73 |
74 | 75 | 82 | 83 |

VAST-2-SGAI

84 |
85 |
86 |
87 | 88 |

Sample API VASTID (LIVE w/WebRTC)

89 |

90 | HLS, live, 1 ad parsed form 91 | this Google IMA VAST example 92 |

93 |

94 | Source: 95 | WebRTC Publish Page 96 |

97 | 98 | 99 | 100 |
101 | API Event:
...

102 | Current Time:

103 | Program Date Time:
...

104 | Interstitial:
...


105 |
106 | 107 |
108 |
109 | 110 | 187 | 188 | -------------------------------------------------------------------------------- /public/samples/sample-api-vastid-live3/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API 1 3 | 4 | 5 | 6 | 69 | 70 | 71 | 72 |
73 |
74 | 75 | 82 | 83 |

VAST-2-SGAI

84 |
85 |
86 |
87 | 88 |

Sample API VASTID (LIVE w/rtsp Camera)

89 |

90 | HLS, live, 1 ad parsed form 91 | this Google IMA VAST example 92 |

93 |

94 | Source: rtsp Camera 95 |

96 | 97 | 98 | 99 |
100 | API Event:
...

101 | Current Time:

102 | Program Date Time:
...

103 | Interstitial:
...


104 |
105 | 106 |
107 |
108 | 109 | 186 | 187 | -------------------------------------------------------------------------------- /public/samples/sample-api-vastid-live4/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API 1 3 | 4 | 5 | 6 | 69 | 70 | 71 | 72 |
73 |
74 | 75 | 82 | 83 |

VAST-2-SGAI

84 |
85 |
86 |
87 | 88 |

Sample API VASTID (LIVE w/rtmp)

89 |

90 | HLS, live, 1 ad parsed form 91 | this Google IMA VAST example 92 |

93 |

94 | Source: rtmp

ffmpeg -stats -re -f lavfi -i testsrc=size=1280x720,format=yuv420p -f lavfi -i anullsrc -vf "drawtext=fontsize=120:fontcolor=black:text='%{gmtime\:%T}':x=24:y=24,drawtext=fontsize=120:fontcolor=white:text='%{gmtime\:%T}':x=20:y=20"  -c:v libx264 -g 50 -c:a aac -f flv rtmp://demo.entrypoint.cloud.wowza.com/rtmp/mystream
95 |

96 | 97 | 98 | 99 |
100 | API Event:
...

101 | Current Time:

102 | Program Date Time:
...

103 | Interstitial:
...


104 |
105 | 106 |
107 |
108 | 186 | 187 | -------------------------------------------------------------------------------- /public/samples/sample-api-vastid/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API 1 3 | 4 | 67 | 68 | 69 | 70 |
71 |
72 | 73 | 80 | 81 |

VAST-2-SGAI

82 |
83 |
84 |
85 |

Sample API VASTID

86 |

87 | HLS, interstitial@5sec, 1 ad parsed form 88 | this Google IMA VAST example 89 |

90 |

91 | API Call: 92 | try out 95 |

96 | 97 |
98 | 99 |
100 |
101 | 102 | 175 | 176 | -------------------------------------------------------------------------------- /public/samples/sample-api-vastid/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/sample-api-vasturl/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample API 1 3 | 4 | 67 | 68 | 69 | 70 |
71 |
72 | 73 | 80 | 81 |

VAST-2-SGAI

82 |
83 |
84 |
85 |

Sample API VASTURL

86 |

87 | HLS, interstitial@5sec, 1 ad parsed form 88 | this Google IMA VAST example 89 |

90 |

91 | API Call: 92 | try out 96 |

97 | 98 |
99 | 100 |
101 |
102 | 103 | 176 | 177 | -------------------------------------------------------------------------------- /public/samples/sample-api-vasturl/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/sample-edit-vast/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sample VAST 1 7 | 8 | 9 | 10 | 11 | 149 | 150 | 151 |
152 |
153 | 154 | 155 | 156 |

VAST-2-SGAI

157 |
158 |
159 |
160 |
161 |

Try your own VAST!

162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 174 | 176 | 177 |
178 |
179 | 180 |
181 |
182 |
183 |
184 |

VAST File

185 |
Waiting for VAST file...
186 | 187 |
188 |
189 |

Parsed Asset List

190 |
Waiting for asset-list...
191 |
192 |
193 | 194 | 302 | 303 | -------------------------------------------------------------------------------- /public/samples/sample-edit-vast/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="duration" 3 | #EXT-X-DEFINE:QUERYPARAM="vasturl" 4 | #EXT-X-DEFINE:QUERYPARAM="startDate" 5 | #EXT-X-DEFINE:QUERYPARAM="resumeOffset" 6 | #EXT-X-DEFINE:QUERYPARAM="restrict" 7 | 8 | 9 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 10 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 11 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 12 | 13 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 14 | video/800kbit.m3u8?duration={$duration}&vasturl={$vasturl}&startDate={$startDate}&resumeOffset={$resumeOffset}&restrict={$restrict} 15 | 16 | #EXT-X-ENDLIST 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/samples/sample-vast-1/index.html: -------------------------------------------------------------------------------- 1 | 2 | Sample VAST 1 3 | 4 | 67 | 68 | 69 | 70 |
71 |
72 | 73 | 80 | 81 |

VAST-2-SGAI

82 |
83 |
84 |
85 |

Sample VAST 1

86 |

87 | HLS, interstitial@5sec, 1 ad parsed form 88 | this Google IMA VAST example 89 |

90 | 91 |
92 | 93 |
94 |
95 | 96 | 169 | 170 | -------------------------------------------------------------------------------- /public/samples/sample-vast-1/main.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-DEFINE:QUERYPARAM="user" 3 | #EXT-X-DEFINE:QUERYPARAM="content" 4 | 5 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,URI="audio/stereo/en/128kbit.m3u8" 6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",LANGUAGE="dubbing",NAME="Dubbing",DEFAULT=NO,AUTOSELECT=YES,URI="audio/stereo/none/128kbit.m3u8" 8 | 9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=831270,CODECS="avc1.4d4015,mp4a.40.2",AUDIO="stereo",RESOLUTION=638x272 10 | video/800kbit.m3u8?user={$user}&content={$content} 11 | 12 | #EXT-X-ENDLIST 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_01_15s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Single Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:15 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_02_30s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Single Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:30 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_03_30s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Single Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:30 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_04_15s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Single Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:15 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_05_15s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Single Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:15 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_06-7_30s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Double Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:15 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | Sample 48 | External - Double Inline Linear 49 | 50 | 51 | 52 | 53 | 54 | 55 | 00:00:15 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_08_15s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Single Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:15 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/samples/sample-vast-set/vast_09_15s.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | External - Single Inline Linear 7 | 8 | 9 | 10 | 11 | 12 | 13 | 00:00:15 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/samples/wse_api.js: -------------------------------------------------------------------------------- 1 | function sendGetRequest(app,stream,attempt=1) 2 | { 3 | console.log("Sending ad insert attempt:"+attempt); 4 | $('#response').text("..."); 5 | var adObject = new Object(); 6 | adObject.id = app + "-ad"; 7 | //adObject.start_date = "+10"; 8 | adObject.asset_list = "https://vast2sgai.qualabs.com/api/asset-list?vasturl=http://vast2sgai.qualabs.com/samples/sample-api-vastid-live/vast-sample.xml&test=false&user=user&content=content" 9 | 10 | $.post({ 11 | url: 'https://demo.entrypoint.cloud.wowza.com/v1/ads/applications/'+app+'/streams/' + stream, 12 | type: 'post', 13 | timeout: 2000, 14 | tryCount : 0, 15 | retryLimit : 3, 16 | dataType: 'json', 17 | contentType: "application/json", 18 | data: JSON.stringify(adObject), 19 | success: function (okdata) { 20 | msg = "OK:"+okdata; 21 | console.log(msg); 22 | $('#response').text(msg.trim()); 23 | }, 24 | error: function (xhr, ajaxOptions, thrownError) { 25 | if(xhr.status == 0) { 26 | var msg = "insert failed:" + xhr.status + "=>" + xhr.responseText + " (attempt:" + attempt + ")" 27 | $('#response').text(msg.trim()); 28 | console.log(msg); 29 | if(attempt < 3) { 30 | console.log("Retyring"); 31 | sendGetRequest(app,stream,attempt+1); 32 | } else { 33 | $('#response').text(msg); 34 | } 35 | } else if(xhr.status == 200) { 36 | msg = "insert ok:" + xhr.status + "=>" + xhr.responseText; 37 | console.log(msg); 38 | $('#response').text(msg.trim()); 39 | } 40 | else { 41 | msg = "insert failed:" + xhr.status + "=>" + xhr.responseText; 42 | console.log(msg); 43 | $('#response').text(msg.trim()); 44 | } 45 | } 46 | }); 47 | }; 48 | 49 | function sendDeleteRequest(app,stream,attempt=1) 50 | { 51 | console.log("Sending ad remove attempt:"+attempt); 52 | $('#response').text("..."); 53 | var adObject = new Object(); 54 | $.post({ 55 | url: 'https://demo.entrypoint.cloud.wowza.com/v1/ads/applications/'+app+'/streams/' + stream, 56 | type: 'delete', 57 | dataType: 'json', 58 | timeout: 2000, 59 | tryCount : 0, 60 | retryLimit : 3, 61 | contentType: "application/json", 62 | data: JSON.stringify(adObject), 63 | success: function (okdata) { 64 | msg = "Deleted"; 65 | console.log(msg); 66 | $('#response').text(msg.trim()); 67 | }, 68 | error: function (xhr, ajaxOptions, thrownError) { 69 | if(xhr.status == 0) { 70 | msg = "delete failed:" + xhr.status + "=>" + xhr.responseText + " (attempt:" + attempt + ")"; 71 | console.log(msg); 72 | if(attempt < 3) { 73 | console.log("trying..."); 74 | sendDeleteRequest(app,stream,attempt+1); 75 | } else { 76 | $('#response').text(msg.trim()); 77 | } 78 | } else if(xhr.status == 204) { 79 | msg = "delete ok:" + xhr.status + "=>" + xhr.responseText; 80 | console.log(msg); 81 | $('#response').text(msg.trim()); 82 | } 83 | else { 84 | msg = "delete failed:" + xhr.status + "=>" + xhr.responseText; 85 | console.log(msg); 86 | $('#response').text(msg.trim()); 87 | } 88 | } 89 | }); 90 | } 91 | 92 | function checkTime(i) { 93 | // add a zero in front of numbers<10 94 | if (i < 10) { 95 | i = "0" + i; 96 | } 97 | return i; 98 | } 99 | 100 | function startTime() { 101 | var today = new Date(); 102 | $('#time').text(checkTime(today.getUTCHours()) + ":" + checkTime(today.getUTCMinutes()) + ":" + checkTime(today.getUTCSeconds())); 103 | 104 | if (hls && hls.playingDate){ 105 | var programDateTime = hls.playingDate 106 | $('#program-date-time').text(checkTime(programDateTime.getUTCHours()) + ":" + checkTime(programDateTime.getUTCMinutes()) + ":" + checkTime(programDateTime.getUTCSeconds())); 107 | } 108 | setTimeout(function() { 109 | startTime() 110 | }, 500); 111 | } -------------------------------------------------------------------------------- /public/wse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/montevideo-tech/vast-2-sgai/b883cafcdc417a4afac4273281fcdc1880c2f1ef/public/wse.png -------------------------------------------------------------------------------- /src/config/vast-mapping.js: -------------------------------------------------------------------------------- 1 | const vastMappingEnv = process.env.VAST_MAPPING_JSON || ""; 2 | 3 | let vastMapping; 4 | try { 5 | vastMapping = JSON.parse(vastMappingEnv); 6 | } catch { 7 | vastMapping = {}; 8 | } 9 | 10 | module.exports = vastMapping; 11 | -------------------------------------------------------------------------------- /src/middlewares/params.js: -------------------------------------------------------------------------------- 1 | const { middlewareJWTQuery } = require("../utils/jwt"); 2 | const { vastServerWhitelistMiddleware } = require("./whitelist"); 3 | const { vastIdMiddleware } = require("./vast-id"); 4 | 5 | function paramsMiddleware(req, res, next) { 6 | const { jwt, vasturl, vastid } = req.query; 7 | 8 | const params = [jwt, vasturl, vastid].filter((param) => param !== undefined); 9 | if (params.length > 1) { 10 | return res 11 | .status(400) 12 | .json({ 13 | message: "Provide only one param at a time: jwt, vasturl or vastid", 14 | }); 15 | } 16 | 17 | if (jwt) { 18 | return middlewareJWTQuery(req, res, next); 19 | } else if (vasturl) { 20 | return vastServerWhitelistMiddleware(req, res, next); 21 | } else if (vastid) { 22 | return vastIdMiddleware(req, res, next); 23 | } else { 24 | return res 25 | .status(400) 26 | .json({ 27 | message: "No valid query param provided. Use jwt, vasturl or vastid", 28 | }); 29 | } 30 | } 31 | 32 | module.exports = { paramsMiddleware }; 33 | -------------------------------------------------------------------------------- /src/middlewares/vast-id.js: -------------------------------------------------------------------------------- 1 | const vastMapping = require("../config/vast-mapping"); 2 | 3 | function vastIdMiddleware(req, res, next) { 4 | const { vastid } = req.query; 5 | if (!vastid) { 6 | return res.status(400).json({ message: "Missing vastid query param" }); 7 | } 8 | 9 | const vastidurl = vastMapping[vastid]; 10 | if (!vastidurl) { 11 | return res 12 | .status(404) 13 | .json({ message: `No VAST URL found for id: ${vastid}` }); 14 | } 15 | 16 | req.query.vastidurl = vastidurl; 17 | return next(); 18 | } 19 | 20 | module.exports = { vastIdMiddleware }; 21 | -------------------------------------------------------------------------------- /src/middlewares/whitelist.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config({ path: "src/.env" }); 2 | 3 | const { 4 | VAST_WHITELIST: vastWhitelist = "", 5 | ORIGIN_WHITELIST: originWhitelist = "" 6 | } = process.env; 7 | 8 | function vastServerWhitelistMiddleware(req, res, next) { 9 | const { vasturl } = req.query; 10 | 11 | if (vastWhitelist === "") { 12 | return next(); 13 | } 14 | 15 | if (!vasturl) { 16 | return res.status(400).json({ message: "Missing vasturl query param" }); 17 | } 18 | 19 | try { 20 | const hostname = new URL(vasturl).hostname; 21 | const isAllowedVast = vastWhitelist.split(",").includes(hostname); 22 | 23 | if (isAllowedVast) { 24 | return next(); 25 | } else { 26 | return res 27 | .status(403) 28 | .json({ message: "Forbidden: VAST URL not allowed" }); 29 | } 30 | } catch { 31 | return res.status(400).json({ message: "Bad Request: Invalid VAST URL" }); 32 | } 33 | } 34 | 35 | function originWhitelistMiddleware(req, res, next) { 36 | const origin = req.headers.origin; 37 | 38 | if (originWhitelist === "") { 39 | return next(); 40 | } 41 | 42 | if (!origin) { 43 | return res 44 | .status(400) 45 | .json({ message: "Bad Request: Origin header is required" }); 46 | } 47 | 48 | try { 49 | const hostname = new URL(origin).hostname; 50 | const isAllowedOrigin = originWhitelist.split(",").includes(hostname); 51 | 52 | if (isAllowedOrigin) { 53 | return next(); 54 | } else { 55 | return res 56 | .status(403) 57 | .json({ message: "Forbidden: Origin host not allowed" }); 58 | } 59 | } catch { 60 | return res 61 | .status(400) 62 | .json({ message: "Bad Request: Invalid Origin header" }); 63 | } 64 | } 65 | 66 | module.exports = { originWhitelistMiddleware, vastServerWhitelistMiddleware }; 67 | -------------------------------------------------------------------------------- /src/routes/api.js: -------------------------------------------------------------------------------- 1 | // routes/api.js 2 | require("dotenv").config(); 3 | const express = require("express"); 4 | const bodyParser = require("body-parser"); 5 | 6 | const getVideoManifests = require("../utils/vast-parser.js"); 7 | const getListMPD = require("../utils/list-mpd-generator.js"); 8 | const { signJWT } = require("../utils/jwt.js"); 9 | const updateQueryParams = require("../utils/replace-queryparams.js"); 10 | 11 | const { originWhitelistMiddleware } = require("../middlewares/whitelist.js"); 12 | const { paramsMiddleware } = require("../middlewares/params.js"); 13 | 14 | const { AdCreativeSignalingMapper } = require("../trackingEvents/tracking-events.js"); 15 | 16 | const API_DISABLE_SIGN = process.env.API_DISABLE_SIGN == "true"; 17 | 18 | const router = express.Router(); 19 | 20 | async function getAds(req, manifestType) { 21 | //remove all the reserved params from the query params 22 | // eslint-disable-next-line no-unused-vars 23 | const { jwt, vasturl, vastidurl, vastid, ...queryParams } = req.query; 24 | 25 | let jwturl = ""; 26 | if (req.jwtPayload) { 27 | jwturl = req.jwtPayload.url; 28 | } 29 | 30 | //get the url from decoded jwt, plain vasturl or vastid mapping 31 | const url = jwturl || vasturl || vastidurl; 32 | 33 | const finalUrl = updateQueryParams(url, queryParams); 34 | req.log.debug(`initial VAST URL : ${url}`); 35 | req.log.debug(`final VAST URL: ${finalUrl}`); 36 | return await getVideoManifests(finalUrl, manifestType); 37 | } 38 | 39 | function mapAdCreativeSignaling(ad) { 40 | const mapper = new AdCreativeSignalingMapper(ad); 41 | const mappedTrackingEvents = mapper.map(); 42 | 43 | return mappedTrackingEvents; 44 | } 45 | 46 | // HLS Asset List 47 | router.get( 48 | "/asset-list", 49 | originWhitelistMiddleware, 50 | paramsMiddleware, 51 | async (req, res) => { 52 | let ads; 53 | try { 54 | ads = await getAds(req, "m3u8"); 55 | } catch (error) { 56 | return res.status(400).json({ 57 | error: error.message, 58 | }); 59 | } 60 | const assetList = { ASSETS: [] }; 61 | ads.forEach((ad) => { 62 | 63 | const videoClicksSignaling = { 64 | clickThrough: ad.videoClicks?.clickThrough, 65 | clickTracking: ad.videoClicks?.clickTracking, 66 | customClick: ad.videoClicks?.customClick, 67 | }; 68 | 69 | const trackingEvents = mapAdCreativeSignaling(ad); 70 | assetList.ASSETS.push({ 71 | URI: ad.fileURL, 72 | DURATION: ad.duration, 73 | "X-AD-CREATIVE-SIGNALING": { 74 | version: 2, 75 | type: "slot", 76 | payload: [ 77 | { 78 | type: "linear", 79 | start: 0.0, 80 | duration: ad.duration, 81 | tracking: trackingEvents, 82 | }, 83 | ], 84 | }, 85 | "X-VAST2SGAI-VIDEOCLICKS": videoClicksSignaling, 86 | }); 87 | }); 88 | 89 | res.json(assetList); 90 | } 91 | ); 92 | 93 | // MPEG-DASH MPD List 94 | router.get( 95 | "/list-mpd", 96 | originWhitelistMiddleware, 97 | paramsMiddleware, 98 | async (req, res) => { 99 | const ads = await getAds(req, "mpd"); 100 | res.set("Content-Type", "application/dash+xml"); 101 | res.send(getListMPD(ads)); 102 | } 103 | ); 104 | 105 | // Sign JWT 106 | router.all("/sign", bodyParser.json(), (req, res) => { 107 | if (API_DISABLE_SIGN) res.status(401).json({ error: "API disabled" }); 108 | const url = req.body.url || req.query.url; 109 | if (!url) 110 | res.status(400).json({ 111 | error: 112 | "You must send the 'url' as a query parameter when using GET or include it in the JSON body when using POST.", 113 | }); 114 | res.send(signJWT({ url })); 115 | }); 116 | 117 | module.exports = router; -------------------------------------------------------------------------------- /src/routes/samples.js: -------------------------------------------------------------------------------- 1 | // routes/subroutes.js 2 | const express = require("express"); 3 | const VAST = require("@dailymotion/vast-client"); 4 | const { logger } = require("../utils/logger.js"); 5 | 6 | const router = express.Router(); 7 | 8 | // Basic Asset List 9 | router.get("/asset-list-1", (req, res) => { 10 | const assetList = { 11 | ASSETS: [ 12 | { 13 | URI: "https://flipfit-cdn.akamaized.net/flip_hls/661f570aab9d840019942b80-473e0b/video_h1.m3u8", 14 | DURATION: 45.0, 15 | }, 16 | ], 17 | }; 18 | res.log.info("Return sample: asset-list-1"); 19 | res.json(assetList); 20 | }); 21 | 22 | // Ad POD 23 | router.get("/asset-list-2", (req, res) => { 24 | res.json({ 25 | ASSETS: [ 26 | { 27 | URI: "https://flipfit-cdn.akamaized.net/flip_hls/661f570aab9d840019942b80-473e0b/video_h1.m3u8", 28 | DURATION: 45.0, 29 | }, 30 | { 31 | URI: "https://redirector.gvt1.com/api/manifest/hls_variant/id/f1be9c477e89fd68/itag/0/source/dclk_video_ads/acao/yes/cpn/McGcWKwT0_10xtfw/ctier/L/ei/IuBEZ6rhLtrn1sQP0IXQ8A8/hfr/all/ip/0.0.0.0/keepalive/yes/playlist_type/DVR/requiressl/yes/susc/dvc/xpc/Eghovf3BOnoBAQ%3D%3D/expire/1764103074/sparams/expire,ei,ip,requiressl,acao,ctier,source,playlist_type,hfr,id,itag,susc,xpc/sig/AJfQdSswRAIgc0tQOyv3LSTCNBEv8q_nWaSWwq-EdQ870E7JFyy_dVQCICCKy-TAM88ZpQOHiXKAcgfj1ezoru62WiR_A-1epmla/file/index.m3u8", 32 | DURATION: 10.0, 33 | }, 34 | ], 35 | }); 36 | }); 37 | 38 | router.get("/sample-vast-1", async (req, res) => { 39 | const vastClient = new VAST.VASTClient(); 40 | const parsedVast = await vastClient.get( 41 | "http://localhost:3000/samples/sample-vast-1/vast-sample.xml" 42 | ); 43 | 44 | logger.info(parsedVast); 45 | logger.info(parsedVast.ads[0]); 46 | logger.info(parsedVast.ads[0].creatives[0]); 47 | logger.info(parsedVast.ads[0].creatives[0].mediaFiles[4]); 48 | 49 | const fileURL = parsedVast.ads[0].creatives[0].mediaFiles[4].fileURL; 50 | const duration = parsedVast.ads[0].creatives[0].duration; 51 | res.json({ 52 | ASSETS: [ 53 | { 54 | URI: fileURL, 55 | DURATION: duration, 56 | }, 57 | ], 58 | }); 59 | }); 60 | 61 | module.exports = router; 62 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const express = require("express"); 3 | const path = require("path"); 4 | const cors = require("cors"); 5 | 6 | const samples = require("./routes/samples.js"); 7 | const api = require("./routes/api.js"); 8 | const { httpLogger, logger } = require("./utils/logger.js"); 9 | 10 | const app = express(); 11 | const PORT = process.env.PORT || 3000; 12 | 13 | app.use(cors()); 14 | 15 | app.use("/api/", httpLogger, api); 16 | app.use("/api/samples/", httpLogger, samples); 17 | 18 | // Static samples 19 | app.use(express.static(path.join(__dirname, "../public"))); 20 | 21 | // Start the server 22 | app.listen(PORT, () => { 23 | logger.info(`VAST-2-SGAI is running on http://localhost:${PORT}`); 24 | }); 25 | 26 | module.exports = app; 27 | -------------------------------------------------------------------------------- /src/trackingEvents/tracking-events.js: -------------------------------------------------------------------------------- 1 | const { logger } = require("../utils/logger.js"); 2 | 3 | const IMPRESSION = "impression"; 4 | const PROGRESS = "progress"; 5 | 6 | class TrackingEvent { 7 | constructor(type, duration, urls) { 8 | // console.log(">>>TRACKING EVT: ", type) 9 | this.type = type; 10 | //this.duration = duration; 11 | this.urls = urls; 12 | //this.start = start; 13 | 14 | if (this.type.startsWith(PROGRESS)) { 15 | const offsetValue = this.type.split("-")[1]; 16 | if (offsetValue !== null) { 17 | this.offset = 18 | typeof offsetValue === "string" && offsetValue.includes("%") 19 | ? (duration * parseFloat(offsetValue.replace("%", ""))) / 100 20 | : parseFloat(offsetValue); 21 | 22 | this.type = PROGRESS; 23 | 24 | if ( 25 | isNaN(this.offset) || 26 | this.offset < 0 || 27 | this.offset > this.duration 28 | ) { 29 | throw new Error(`Invalid start value: ${offset}`); 30 | } 31 | } 32 | } else { 33 | switch (type) { 34 | case "start": 35 | this.offset = 0; 36 | break; 37 | 38 | case "firstQuartile": 39 | this.offset = duration * 0.25; 40 | break; 41 | 42 | case "midpoint": 43 | this.offset = duration * 0.5; 44 | break; 45 | 46 | case "thirdQuartile": 47 | this.offset = duration * 0.75; 48 | break; 49 | 50 | case "complete": 51 | this.offset = duration; 52 | break; 53 | } 54 | } 55 | } 56 | } 57 | 58 | class AdCreativeSignalingMapper { 59 | constructor(ad) { 60 | this.ad = ad; 61 | console.log(">>>> Ad Tracking", this.ad.trackingEvents); 62 | } 63 | 64 | map() { 65 | const newTrackingEvents = this.ad.trackingEvents; 66 | 67 | if (this.ad?.impressions.length > 0) 68 | newTrackingEvents[IMPRESSION] = this.ad.impressions.map( 69 | (impression) => impression.url 70 | ); 71 | 72 | const trackingEvents = Object.entries(this.ad.trackingEvents) 73 | .map(([eventType, urls]) => { 74 | try { 75 | const event = new TrackingEvent(eventType, this.ad.duration, urls); 76 | //console.log(">>> track evt: ", event) 77 | return event; 78 | } catch (error) { 79 | logger.warn( 80 | `Skipping invalid event: ${eventType}, Error: ${error.message}` 81 | ); 82 | 83 | return null; 84 | } 85 | }) 86 | .filter(Boolean); 87 | 88 | return trackingEvents; 89 | } 90 | } 91 | 92 | module.exports = { 93 | AdCreativeSignalingMapper, 94 | TrackingEvent, 95 | }; 96 | -------------------------------------------------------------------------------- /src/utils/jwt.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config({ path: "src/.env" }); 2 | const jwt = require("jsonwebtoken"); 3 | 4 | const SECRET_KEY = process.env.JWT_SECRET_KEY; 5 | const BYPASS_VALIDATION = process.env.JWT_BYPASS_VALIDATION == "true"; 6 | const EXPIRES_IN = process.env.JWT_EXPIRES_IN || "100y"; 7 | 8 | function signJWT(payload, options = {}) { 9 | const opt = { ...options, expiresIn: EXPIRES_IN }; 10 | return jwt.sign(payload, SECRET_KEY, opt); 11 | } 12 | 13 | function getJWT(token) { 14 | if (BYPASS_VALIDATION) return jwt.decode(token); 15 | try { 16 | return jwt.verify(token, SECRET_KEY); 17 | } catch (error) { 18 | throw new Error("Invalid token: " + error); 19 | } 20 | } 21 | 22 | function middlewareJWTHeader(req, res, next) { 23 | const token = req.headers["authorization"]?.split(" ")[1]; 24 | if (!token) { 25 | return res.status(401).json({ error: "Token not found" }); 26 | } 27 | try { 28 | req.jwtPayload = getJWT(token); 29 | next(); 30 | } catch { 31 | return res.status(403).json({ error: "Invalid token" }); 32 | } 33 | } 34 | 35 | function middlewareJWTQuery(req, res, next) { 36 | const token = req.query.jwt; 37 | 38 | if (!token) 39 | return res.status(401).json({ error: "jwt query parameter not found" }); 40 | try { 41 | req.jwtPayload = getJWT(token); 42 | } catch { 43 | return res.status(403).json({ error: "Invalid token" }); 44 | } 45 | 46 | if (!req.jwtPayload.url) 47 | return res.status(401).json({ error: "url not found in jwt" }); 48 | 49 | next(); 50 | } 51 | 52 | module.exports = { signJWT, getJWT, middlewareJWTHeader, middlewareJWTQuery }; 53 | -------------------------------------------------------------------------------- /src/utils/list-mpd-generator.js: -------------------------------------------------------------------------------- 1 | 2 | const DASH_MIN_BUFFER_TIME = "PT1S"; 3 | const DASH_RESOLUTION_TIME_OFFSET = "10"; 4 | 5 | function getListMPD(ads){ 6 | const publishTime = new Date().toISOString(); // Current publish time 7 | 8 | // Initialize MPD XML structure 9 | let mpdXml = ` 10 | \n`; 16 | 17 | // Generate Period and ImportedMPD elements 18 | ads.forEach((ad, index) => { 19 | const earliestResolutionTimeOffset = index === 0 ? 0 : DASH_RESOLUTION_TIME_OFFSET; 20 | mpdXml += ` 21 | 22 | \n`; 23 | }); 24 | 25 | // Close MPD element 26 | mpdXml += ""; 27 | return mpdXml; 28 | }; 29 | 30 | module.exports = getListMPD; -------------------------------------------------------------------------------- /src/utils/logger.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const pino = require("pino"); 3 | const pinoHttp = require("pino-http"); 4 | 5 | const logger = pino({ 6 | level: process.env.PINO_LOG_LEVEL || "info", 7 | ...(process.env.PINO_LOG_LEVEL === "debug" ? { 8 | transport: { 9 | target: "pino-pretty", 10 | }, 11 | } : {}) 12 | }); 13 | 14 | const httpLogger = pinoHttp({ 15 | logger, 16 | serializers: { 17 | req: pino.stdSerializers.wrapRequestSerializer((req) => { 18 | return { 19 | id: req.raw.id, 20 | method: req.raw.method, 21 | path: req.raw.url.split("?")[0], // Remove query params which might be sensitive 22 | // Allowlist useful headers 23 | headers: { 24 | host: req.raw.headers.host, 25 | "user-agent": req.raw.headers["user-agent"], 26 | referer: req.raw.headers.referer, 27 | }, 28 | }; 29 | }), 30 | res: pino.stdSerializers.wrapResponseSerializer((res) => { 31 | return { 32 | statusCode: res.raw.statusCode, 33 | // Allowlist useful headers 34 | headers: { 35 | "content-type": res.raw.headers["content-type"], 36 | "content-length": res.raw.headers["content-length"], 37 | }, 38 | }; 39 | }), 40 | }, 41 | }); 42 | 43 | module.exports = { httpLogger, logger }; 44 | -------------------------------------------------------------------------------- /src/utils/replace-queryparams.js: -------------------------------------------------------------------------------- 1 | function updateQueryParams(url, params) { 2 | const urlObj = new URL(url); 3 | // Update or add the params 4 | for (const [key, value] of Object.entries(params)) { 5 | urlObj.searchParams.set(key, value); 6 | } 7 | 8 | // Update or add the params 9 | for (const [key, value] of Object.entries(params)) { 10 | urlObj.searchParams.set(key, value); 11 | } 12 | 13 | return urlObj.toString(); 14 | } 15 | 16 | module.exports = updateQueryParams; 17 | -------------------------------------------------------------------------------- /src/utils/vast-parser.js: -------------------------------------------------------------------------------- 1 | const VAST = require("@dailymotion/vast-client"); 2 | const { logger } = require("./logger.js"); 3 | /** 4 | * Fetches video manifests from a VAST URL. 5 | * 6 | * @param {string} vastUrl - The URL of the VAST XML. It should be a valid URL string. 7 | * @param {string} manifestType - The type of manifest to filter. It can be either '.mpd' or '.m3u8'. 8 | * @returns {Promise} A promise that resolves to an array of objects containing file URLs and durations. 9 | */ 10 | async function getVideoManifests(vastUrl, manifestType) { 11 | const vastClient = new VAST.VASTClient(); 12 | let parsedVast; 13 | try { 14 | parsedVast = await vastClient.get(vastUrl, { resolveAll: true }); 15 | } catch (error) { 16 | logger.error( 17 | "Failed to fetch VAST XML", 18 | error 19 | ); 20 | throw new Error(error.message); 21 | } 22 | 23 | const result = []; 24 | 25 | if (parsedVast.ads) { 26 | for (let i = 0; i < parsedVast.ads.length; i++) { 27 | const ad = parsedVast.ads[i]; 28 | const { impressionURLTemplates } = ad; 29 | if (ad.creatives) { 30 | for (let j = 0; j < ad.creatives.length; j++) { 31 | const creative = ad.creatives[j]; 32 | const duration = creative.duration; 33 | if (creative.mediaFiles) { 34 | for (let k = 0; k < creative.mediaFiles.length; k++) { 35 | const mediaFile = creative.mediaFiles[k]; 36 | if ( 37 | mediaFile.fileURL && 38 | mediaFile.fileURL.includes(manifestType) 39 | ) { 40 | result.push({ 41 | fileURL: mediaFile.fileURL, 42 | duration, 43 | trackingEvents: creative.trackingEvents, 44 | videoClicks: { 45 | clickThrough: creative.videoClickThroughURLTemplate, 46 | clickTracking: creative.videoClickTrackingURLTemplates, 47 | customClick: creative.videoCustomClickURLTemplates 48 | }, 49 | impressions: impressionURLTemplates, 50 | }); 51 | } 52 | } 53 | } 54 | 55 | } 56 | } 57 | } 58 | } 59 | 60 | return result; 61 | } 62 | 63 | module.exports = getVideoManifests; 64 | --------------------------------------------------------------------------------