├── .github
├── CONTRIBUTING.md
├── assets
│ ├── 1-cd-tab.png
│ ├── 2-run-workflow.png
│ └── 3-compare-tags.png
├── dependabot.yml
└── workflows
│ ├── cd.yml
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── examples
└── default-provider
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ ├── (default)
│ │ ├── background-video
│ │ │ └── page.tsx
│ │ ├── custom-player
│ │ │ ├── page.tsx
│ │ │ └── player.tsx
│ │ ├── custom-theme
│ │ │ └── page.tsx
│ │ ├── dash-source
│ │ │ └── page.tsx
│ │ ├── hls-source
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── mp4-source
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ ├── slotted-poster
│ │ │ └── page.tsx
│ │ ├── source-tag
│ │ │ └── page.tsx
│ │ └── string-source
│ │ │ └── page.tsx
│ ├── (fullscreen)
│ │ ├── background-video-fullscreen
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── api
│ │ └── video
│ │ │ └── route.js
│ ├── favicon.ico
│ ├── globals.css
│ ├── icon.svg
│ ├── nav.tsx
│ ├── sidebar-nav.tsx
│ └── theme-toggle.js
│ ├── images
│ └── get-started-poster.jpg
│ ├── next-video.mjs
│ ├── next.config.mjs
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ ├── country-clouds
│ └── get-started.vtt
│ ├── tsconfig.json
│ ├── video.d.ts
│ └── videos
│ ├── country-clouds.mp4.json
│ ├── get-started.mp4.json
│ └── storage.googleapis.com_muxdemofiles_mux.mp4.json
├── package-lock.json
├── package.json
├── src
├── assets.ts
├── cli.ts
├── cli
│ ├── init.ts
│ ├── lib
│ │ ├── json-configs.ts
│ │ └── next-config.ts
│ └── sync.ts
├── components
│ ├── alert.tsx
│ ├── background-video.tsx
│ ├── players
│ │ ├── background-player.tsx
│ │ ├── default-player.tsx
│ │ └── media
│ │ │ └── index.tsx
│ ├── types.ts
│ ├── utils.ts
│ ├── video-loader.ts
│ └── video.tsx
├── config.ts
├── constants.ts
├── handlers
│ ├── api-request.ts
│ └── local-upload.ts
├── process.ts
├── providers
│ ├── amazon-s3
│ │ ├── provider.ts
│ │ └── transformer.ts
│ ├── backblaze
│ │ ├── provider.ts
│ │ └── transformer.ts
│ ├── cloudflare-r2
│ │ ├── provider.ts
│ │ └── transformer.ts
│ ├── mux
│ │ ├── provider.ts
│ │ └── transformer.ts
│ ├── providers.ts
│ ├── transformers.ts
│ └── vercel-blob
│ │ ├── provider.ts
│ │ └── transformer.ts
├── request-handler.ts
├── setup-next-video.ts
├── utils
│ ├── logger.ts
│ ├── provider.ts
│ ├── queue.ts
│ ├── r2.ts
│ ├── s3.ts
│ └── utils.ts
├── video-handler.ts
├── webpack
│ ├── video-json-loader.ts
│ └── video-raw-loader.ts
└── with-next-video.ts
├── tests
├── cli
│ ├── lib
│ │ ├── json-configs.test.ts
│ │ └── next-config.test.ts
│ └── sync.test.ts
├── components
│ ├── alert.test.tsx
│ ├── utils.test.tsx
│ ├── video-loader.test.ts
│ └── video.test.tsx
├── config.test.ts
├── factories
│ ├── BBB-720p-1min.mp4.json
│ ├── next.config.js
│ ├── next.config.mjs
│ ├── next.config.ts
│ ├── next.function.config.js
│ ├── next.promise.config.js
│ ├── package.dep.json
│ ├── package.devDep.json
│ └── package.none.json
├── next.config.js
├── providers
│ ├── amazon-s3
│ │ └── transformer.test.ts
│ ├── backblaze
│ │ └── transformer.test.ts
│ ├── mux
│ │ └── transformer.test.ts
│ └── vercel-blob
│ │ └── transformer.test.ts
├── utils.test.ts
├── utils
│ ├── fake-mux.ts
│ └── provider.test.ts
├── video-handler.test.ts
└── with-next-video.test.ts
├── tsconfig.json
└── video-types
└── global.d.ts
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to `next-video`
2 |
3 | - [Questions](#questions)
4 | - [Bugs and Issues](#issues)
5 | - [Documentation Updates](#documentation)
6 | - [Feature Requests](#features)
7 | - [Submitting a Pull Request](#pull-requests)
8 | - [Merging a Pull Request](#merging)
9 | - [Releasing](#releasing)
10 |
11 | ## Questions
12 |
13 | Have a question? Want to start a discussion? For now, you can simply [Open a New Discussion](/discussions/new), and choose a fitting category and title.
14 |
15 | ## Bugs and Issues
16 |
17 | If you think you've found a bug, make sure you review and fill out a [Bug Report](/issues/new/choose) before starting any work. This will ensure for both yourself and the maintainers that the issue in question can be properly confirmed, reproduced, smoke tested, etc. Once done, if you want to try to fix the issue yourself, go ahead and follow our [Submitting a Pull Request](#pull-requests) guide. Contributions are welcome and encouraged!
18 |
19 | ## Documentation Updates
20 |
21 | Our documentation update request requirements are similar to the requirements for [Bugs and Issues](#issues).
22 |
23 | ## Feature Requests
24 |
25 | For feature requests, you can start by reviewing and filling out a [Feature Request](/discussions/new). Unlike bug fixes, Feature Requests will likely require more discussion from the maintainers, including whether or not it is consistent with our overall architectural goals, our timeline and priorities, and the like. Once done, assuming you've gotten a 👍 to work on the feature, go ahead and follow our [Submitting a Pull Request](#pull-requests) guide.
26 |
27 | ## Submitting a Pull Request
28 |
29 | Before submitting a pull request, make sure you've reviewed and filled out an appropriate [Issue](/issues/new/choose). We recommend doing this before starting any work, just in case an issue already exists, or it's unlikely the maintainers will be able to review the PR because it e.g. lacks sufficient reproduction steps. In addition, we recommend the following:
30 |
31 | 1. We use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). Please try to prefix your commits according to the type of changes you're making, and try to be as descriptive as possible in your commit messages. For example:
32 |
33 | - For Bug Fixes: `fix: foo by bar`
34 | - For Features: `feat: add video feat`
35 | - For Documentation Updates: `docs: update audio copy`
36 |
37 | 2. Make sure you base your branch off of the latest in `main`, e.g.
38 |
39 | ```shell
40 | git checkout -b my-fix-for-foo main
41 | ```
42 |
43 | 3. When issuing your Pull Request, be sure to [Link it to the corresponding issue(s)](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword)
44 |
45 | 4. Add any additional comments to your PR's description that will help the reviewer(s), such as call outs, open questions, areas that merit extra attention, etc.
46 |
47 | 5. When addressing any feedback, you can simply add it as new commits.
48 |
49 | 6. We use a [rebase strategy](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/configuring-commit-rebasing-for-pull-requests) when merging PR branches into `main`. If your branch has merge conflicts, if possible, please try to resolve them by doing a [`git rebase`](https://git-scm.com/docs/git-rebase) onto `main` and then doing a `git push --force-with-lease`. For example:
50 |
51 | ```shell
52 | git fetch upstream
53 | git rebase --onto main your-old-base my-fix-for-foo
54 | ... resolve any conflicts
55 | git push --force-with-lease
56 | ```
57 |
58 | (See the [git docs](https://git-scm.com/docs/git-rebase) for more details on `git rebase --onto`)
59 |
60 | ## Merging a Pull Request (maintainers only)
61 |
62 | When you choose to squash and merge be sure to prefix the commit message
63 | with `fix:`, `feat:` or similar according to
64 | [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/).
65 |
66 | ## Releasing (maintainers only)
67 |
68 | ### Short version (I've done this before!)
69 | 1. Visit the [GitHub Actions tab](/actions) and
70 | select the "CD" action in the left sidebar.
71 | 2. Click the "Run workflow" dropdown and choose the correct `Version` on the `main` branch.
72 | - In the **Use workflow from** select box, make sure **Branch: main** is selected.
73 | - In the **Version** select box, choose the appropriate value:
74 | - If the commit messages in this release were written using the correct conventional commit style, select `conventional`.
75 | - If the commit messages aren't accurate, manually choose the correct semver version `patch`, `minor`, `major`.
76 | 3. When you're confident with your choices, click the green **Run workflow** button to start the release process.
77 | 4. After a few minutes, a new release will be published. This includes an NPM package, new version tags, and a GitHub release.
78 |
79 | ---
80 | ### Long version (I need more context!)
81 |
82 | This repo uses [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
83 | and GitHub Actions for continuous deployment (CD).
84 |
85 | > If you're unfamiliar with conventional commits, it's a good idea to review the link above before continuing.
86 |
87 | Here's a quick summary of how we use conventional commits in this repository:
88 |
89 | - Commit messages prefixed with `fix:` will notify CD that the release is minimally a `patch` release.
90 | - Commit messages prefixed with `feat:` will notify CD that the release is minimally a `minor` release.
91 | - Commit messages containing `BREAKING CHANGE` in the footer will notify CD that the release is minimally a `major` release.
92 | - All other conventional commits have no impact on the versioning.
93 | ### Review commit messages since last release
94 | To proceed with a release, you should be confident that the commits in your upcoming release accurately reflect the type of version that you intend to release.
95 |
96 | Here's how you can review the commits you're about to release:
97 |
98 |
99 |
100 | 1. Visit [/compare](/compare)
101 | 2. In the **base** select box, choose the tag applied to the previous release. Keep `main` for the **compare** select box value.
102 | 3. Review the list of commits to see if they are appropriately using conventional commits.
103 |
104 | > Note: if you're uncertain about particular commits, you may want to reach out to the author of said commit(s) for clarity
105 |
106 | ### Steps to release a new version
107 |
108 |
109 |
110 | 1. Visit the [GitHub Actions tab](/actions) and
111 | select the "CD" action in the left sidebar.
112 |
113 |
114 |
115 | 2. Click the "Run workflow" dropdown and choose the correct `Version` on the `main` branch.
116 | - In the **Use workflow from** select box, make sure **Branch: main** is selected.
117 | - In the **Version** select box, choose the appropriate value:
118 | - If the commit messages in this release were written using the correct conventional commit style, select `conventional`.
119 | - If the commit messages aren't accurate, manually choose the correct semver version `patch`, `minor`, `major`.
120 | 3. When you're confident with your choices, click the green **Run workflow** button to start the release process.
121 | 4. After a few minutes, a new release will be published. This includes an NPM package, new version tags, and a GitHub release.
122 | 5. That's it! Nice work.
123 |
--------------------------------------------------------------------------------
/.github/assets/1-cd-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/next-video/f14fda2e3e356ea31ea93c4f334bb6cb1eb23d0c/.github/assets/1-cd-tab.png
--------------------------------------------------------------------------------
/.github/assets/2-run-workflow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/next-video/f14fda2e3e356ea31ea93c4f334bb6cb1eb23d0c/.github/assets/2-run-workflow.png
--------------------------------------------------------------------------------
/.github/assets/3-compare-tags.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/next-video/f14fda2e3e356ea31ea93c4f334bb6cb1eb23d0c/.github/assets/3-compare-tags.png
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'npm'
4 | directories:
5 | - '/'
6 | schedule:
7 | interval: 'daily'
8 | commit-message:
9 | prefix: 'fix'
10 | prefix-development: 'chore'
11 | groups:
12 | prod-dependencies:
13 | patterns:
14 | - 'media-chrome'
15 | - 'player.style'
16 | allow:
17 | - dependency-name: 'media-chrome'
18 | - dependency-name: 'player.style'
19 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yml:
--------------------------------------------------------------------------------
1 | name: CD
2 |
3 | concurrency: production
4 |
5 | on:
6 | # Allows you to run this workflow manually from the Actions tab
7 | workflow_dispatch:
8 | inputs:
9 | version:
10 | type: choice
11 | required: true
12 | description: Version
13 | options:
14 | - conventional
15 | - patch
16 | - minor
17 | - major
18 | - prerelease
19 | - from-package
20 | - from-git
21 | prerelease:
22 | type: choice
23 | description: Pre-release
24 | options:
25 | -
26 | - canary
27 | - beta
28 | dryrun:
29 | description: 'Dry-run'
30 | type: boolean
31 |
32 | run-name: Deploy ${{ inputs.version }} ${{ inputs.dryrun && '--dry-run' || '' }} ${{ inputs.prerelease && format('--prerelease {0}', inputs.prerelease) || '' }}
33 |
34 | jobs:
35 | deploy:
36 | runs-on: ubuntu-latest
37 | environment: production
38 | permissions:
39 | contents: write
40 | id-token: write
41 |
42 | env:
43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
44 | CONVENTIONAL_GITHUB_RELEASER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45 |
46 | steps:
47 | - uses: actions/checkout@v4
48 | with:
49 | fetch-depth: 0 # Fetch all history for all tags and branches
50 | - uses: actions/setup-node@v4
51 | with:
52 | node-version: 20
53 | # this line is required for the setup-node action to be able to run the npm publish below.
54 | registry-url: 'https://registry.npmjs.org'
55 | - uses: fregante/setup-git-user@v1
56 | - run: npm ci
57 | - run: npm run build --if-present
58 | - run: npm test
59 | - run: npx --yes wet-run@1.2.2 release ${{ inputs.version }} ${{ inputs.dryrun && '--dry-run' || '' }} ${{ inputs.prerelease && format('--prerelease {0}', inputs.prerelease) || '' }} --provenance --changelog --github-release --log-level verbose
60 | - name: Get NPM version
61 | id: npm-version
62 | uses: martinbeentjes/npm-get-version-action@v1.3.1
63 | - name: Released ${{ steps.npm-version.outputs.current-version}} ✨
64 | run: echo ${{ steps.npm-version.outputs.current-version}}
65 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | env:
10 | MUX_TOKEN_ID: fake-token-id
11 | MUX_TOKEN_SECRET: fake-token-secret
12 |
13 | jobs:
14 | build:
15 | strategy:
16 | matrix:
17 | os: [ubuntu-latest, windows-latest]
18 | node-version: [18, 20]
19 | runs-on: ${{ matrix.os }}
20 | steps:
21 | - uses: actions/checkout@v4
22 | - name: Use Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 | - run: npm ci
27 | - run: npm run build --if-present
28 | - uses: nick-fields/retry@v3
29 | with:
30 | timeout_minutes: 5
31 | max_attempts: 3
32 | command: npm test
33 | - run: npm run coverage
34 | - name: Upload coverage reports to Codecov
35 | uses: codecov/codecov-action@v4
36 | env:
37 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 | example-app/
4 | .parcel-cache/
5 | .vscode/
6 | .env
7 | .DS_Store
8 | coverage/
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Mux, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/examples/default-provider/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "next/core-web-vitals"
4 | }
5 |
--------------------------------------------------------------------------------
/examples/default-provider/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # next-video
39 | videos/*
40 | !videos/*.json
41 | !videos/*.js
42 | !videos/*.ts
43 | public/_next-video
44 |
--------------------------------------------------------------------------------
/examples/default-provider/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/background-video/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import BackgroundVideo from 'next-video/background-video';
3 | import countryClouds from '/videos/country-clouds.mp4?thumbnailTime=0';
4 |
5 | export const metadata: Metadata = {
6 | title: 'next-video - Background Video',
7 | };
8 |
9 | export default function Page() {
10 | return (
11 | <>
12 |
13 |
19 | next-video
20 |
21 | A React component for adding video to your Next.js application.
22 | It extends both the video element and your Next app with features
23 | for automatic video optimization.
24 |
25 |
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/custom-player/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Video from 'next-video';
3 | import ReactPlayer from './player'
4 | import countryClouds from '/videos/country-clouds.mp4';
5 |
6 | export const metadata: Metadata = {
7 | title: 'next-video - Custom Player',
8 | };
9 |
10 | export default function Page() {
11 | return (
12 | <>
13 |
20 | >
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/custom-player/player.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { PlayerProps } from 'next-video';
4 | import ReactPlayer from 'react-player';
5 |
6 | export default function Player(props: PlayerProps) {
7 | let { asset, src, poster, blurDataURL, thumbnailTime, ...rest } = props;
8 | let config = { file: { attributes: { poster } } };
9 |
10 | return ;
17 | }
18 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/custom-theme/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Video from 'next-video';
3 | import Instaplay from 'player.style/instaplay/react';
4 | import getStarted from '/videos/get-started.mp4?thumbnailTime=0';
5 |
6 | export const metadata: Metadata = {
7 | title: 'next-video - Custom theme',
8 | };
9 |
10 | export default function Page() {
11 | return (
12 | <>
13 |
18 | >
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/dash-source/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Player from 'next-video/player';
3 |
4 | export const metadata: Metadata = {
5 | title: 'next-video - DASH source',
6 | };
7 |
8 | export default function Page() {
9 | return (
10 | <>
11 |
17 | >
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/hls-source/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Player from 'next-video/player';
3 |
4 | export const metadata: Metadata = {
5 | title: 'next-video - HLS source',
6 | };
7 |
8 | export default function Page() {
9 | return (
10 | <>
11 |
17 | >
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/layout.tsx:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { fileURLToPath } from 'node:url';
3 | import * as fs from 'node:fs/promises';
4 |
5 | import type { Metadata } from 'next';
6 | import { DM_Sans, JetBrains_Mono } from 'next/font/google';
7 | import Nav from '../nav';
8 | import SidebarNav from '../sidebar-nav';
9 | import '../globals.css';
10 |
11 | const dmSans = DM_Sans({ subsets: ['latin'], variable: '--sans' });
12 | const jetBrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--mono' });
13 |
14 | export const metadata: Metadata = {
15 | title: 'next-video',
16 | description:
17 | 'Next Video solves the hard problems with embedding, storing, streaming, and customizing video in your Next.js app.',
18 | };
19 |
20 | // https://francoisbest.com/posts/2023/reading-files-on-vercel-during-nextjs-isr
21 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
22 | const nextJsRootDir = path.resolve(__dirname, '../../')
23 |
24 | export function resolve(importMetaUrl: string, ...paths: string[]) {
25 | const dirname = path.dirname(fileURLToPath(importMetaUrl))
26 | const absPath = path.resolve(dirname, ...paths)
27 | // Required for ISR serverless functions to pick up the file path
28 | // as a dependency to bundle.
29 | return path.resolve(process.cwd(), absPath.replace(nextJsRootDir, '.'))
30 | }
31 |
32 | const themeScript = await fs.readFile(resolve(import.meta.url, `../theme-toggle.js`), 'utf-8');
33 |
34 | export default async function RootLayout({
35 | children,
36 | }: Readonly<{
37 | children: React.ReactNode;
38 | }>) {
39 | return (
40 |
45 |
46 |
47 |
52 |
53 |
54 |
55 | next-video
Playground
56 |
57 |
58 | {children}
59 |
60 |
61 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/mp4-source/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Player from 'next-video/player';
3 |
4 | export const metadata: Metadata = {
5 | title: 'next-video - MP4 Source',
6 | };
7 |
8 | export default function Page() {
9 | return (
10 | <>
11 |
19 | >
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Video from 'next-video';
3 | import getStarted from '/videos/get-started.mp4?thumbnailTime=37';
4 |
5 | export const metadata: Metadata = {
6 | title: 'next-video - Basic example',
7 | };
8 |
9 | export default function Page() {
10 | return (
11 | <>
12 |
24 | >
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/slotted-poster/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Image from 'next/image';
3 | import Video from 'next-video';
4 | import getStarted from '/videos/get-started.mp4';
5 | import getStartedPoster from '/images/get-started-poster.jpg';
6 |
7 | export const metadata: Metadata = {
8 | title: 'next-video - Slotted poster image',
9 | };
10 |
11 | export default function Page() {
12 | return (
13 | <>
14 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/source-tag/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Player from 'next-video/player';
3 |
4 | export const metadata: Metadata = {
5 | title: 'next-video - Player only',
6 | };
7 |
8 | export default function Page() {
9 | return (
10 | <>
11 |
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(default)/string-source/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import Video from 'next-video';
3 |
4 | export const metadata: Metadata = {
5 | title: 'next-video - String source',
6 | };
7 |
8 | export default function Page() {
9 | return (
10 | <>
11 |
14 | >
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(fullscreen)/background-video-fullscreen/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import BackgroundVideo from 'next-video/background-video';
3 | import countryClouds from '/videos/country-clouds.mp4?thumbnailTime=0';
4 |
5 | export const metadata: Metadata = {
6 | title: 'next-video - Background Video',
7 | };
8 |
9 | export default function Page() {
10 | return (
11 | <>
12 |
22 | next-video
23 |
24 | A React component for adding video to your Next.js application. It extends both the
25 | video element and your Next app with features for automatic video optimization.
26 |
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/examples/default-provider/app/(fullscreen)/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { DM_Sans, JetBrains_Mono } from 'next/font/google';
3 | import '../globals.css';
4 |
5 | const dmSans = DM_Sans({ subsets: ['latin'], variable: '--sans' });
6 | const jetBrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--mono' });
7 |
8 | export const metadata: Metadata = {
9 | title: 'next-video',
10 | description:
11 | 'Next Video solves the hard problems with embedding, storing, streaming, and customizing video in your Next.js app.',
12 | };
13 |
14 | export default function getLayout({ children }: { children: React.ReactNode }) {
15 | return (
16 |
17 | {children}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/examples/default-provider/app/api/video/route.js:
--------------------------------------------------------------------------------
1 | export { GET } from 'next-video/request-handler';
2 | // export { GET, POST } from '@/next-video';
3 |
--------------------------------------------------------------------------------
/examples/default-provider/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/next-video/f14fda2e3e356ea31ea93c4f334bb6cb1eb23d0c/examples/default-provider/app/favicon.ico
--------------------------------------------------------------------------------
/examples/default-provider/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'open-props/style';
2 | @import 'open-props/normalize';
3 | @import 'open-props/normalize/light';
4 | @import 'open-props/normalize/dark';
5 | @import 'open-props/switch/light';
6 | @import 'open-props/switch/dark';
7 | @import 'open-props/buttons';
8 | @import 'open-props/colors-hsl';
9 |
10 | :root {
11 | --font-sans: var(--sans);
12 | --font-mono: var(--mono);
13 | --mux-pink: #fa50b5;
14 | }
15 |
16 | [data-theme='light'] {
17 | --nav-icon: var(--gray-7);
18 | --nav-icon-hover: var(--gray-9);
19 | --main-bg: #fff;
20 | --surface-1: #fafaf9;
21 | --surface-2: var(--gray-1);
22 | --border-1: var(--gray-2);
23 | }
24 |
25 | [data-theme='dark'] {
26 | --nav-icon: var(--gray-5);
27 | --nav-icon-hover: var(--gray-2);
28 | --main-bg: #323232;
29 | --surface-1: #383838;
30 | --surface-2: #2f2f2f;
31 | --border-1: var(--gray-9);
32 | }
33 |
34 | body {
35 | display: grid;
36 | grid-template-rows: auto auto 1fr;
37 | }
38 |
39 | header,
40 | main,
41 | footer {
42 | padding-inline: .5rem;
43 | }
44 |
45 | .inner {
46 | max-width: 1280px;
47 | height: 100%;
48 | margin: 0 auto;
49 | border: 1px solid var(--border-1);
50 | border-width: 0 1px;
51 | }
52 |
53 | header {
54 | border-bottom: 1px solid var(--border-1);
55 | }
56 |
57 | header nav {
58 | height: 5rem;
59 | display: flex;
60 | justify-content: flex-end;
61 | }
62 |
63 | header nav a {
64 | display: inline-flex;
65 | align-items: center;
66 | margin: 0;
67 | font-family: 'JetBrains Mono', monospace;
68 | font-size: .875rem;
69 | text-transform: uppercase;
70 | padding: 1.3rem 2rem;
71 | color: var(--nav-icon);
72 | border-left: 1px solid var(--border-1);
73 | }
74 |
75 | header nav :is(a, button):hover {
76 | text-decoration: none;
77 | background: var(--surface-2);
78 | }
79 |
80 | main > .inner {
81 | display: grid;
82 | grid-template-columns: 1fr;
83 | }
84 |
85 | @media (width >= 768px) {
86 | header,
87 | main,
88 | footer {
89 | padding-inline: 1.5rem;
90 | }
91 |
92 | main > .inner {
93 | grid-template-columns: 300px auto;
94 | }
95 |
96 | section {
97 | border-left: 1px solid var(--border-1);
98 | }
99 | }
100 |
101 | footer {
102 | border-top: 1px solid var(--border-1);
103 | }
104 |
105 | aside,
106 | section {
107 | background: var(--main-bg);
108 | padding: 2rem;
109 | }
110 |
111 | aside h1 {
112 | display: flex;
113 | align-items: center;
114 | font-size: var(--font-size-2);
115 | font-weight: var(--font-weight-4);
116 | margin-bottom: 2rem;
117 | }
118 |
119 | aside h1 code {
120 | color: var(--surface-1);
121 | background-color: var(--mux-pink);
122 | padding: 0 var(--size-2);
123 | margin-right: var(--size-2);
124 | }
125 |
126 | aside h1 span {
127 | font-size: var(--font-size-1);
128 | font-family: var(--font-mono);
129 | text-transform: uppercase;
130 | }
131 |
132 | aside p {
133 | font-size: var(--font-size-0);
134 | color: var(--gray-6);
135 | }
136 |
137 | footer .inner {
138 | display: flex;
139 | align-items: flex-end;
140 | justify-content: center;
141 | padding-block: 3rem 2rem;
142 | fill: #242628;
143 | }
144 |
145 | .mux-svg {
146 | width: 2.5rem;
147 | }
148 |
149 | aside nav ul {
150 | list-style: none;
151 | padding: 0;
152 | margin: 0;
153 | line-height: 1.25;
154 | }
155 |
156 | aside nav li {
157 | padding: 0;
158 | }
159 |
160 | aside nav a {
161 | display: grid;
162 | grid-template-columns: 1.15rem auto auto;
163 | align-items: center;
164 | padding: .375rem var(--size-4) .375rem .75rem;
165 | margin: 0;
166 | color: var(--text-1);
167 | font-size: var(--font-size-1);
168 | border-radius: 100vh;
169 | border: 1px solid transparent;
170 | }
171 |
172 | aside nav a:hover {
173 | background-color: var(--surface-1);
174 | text-decoration: none;
175 | }
176 |
177 | aside nav a.active {
178 | background-color: var(--surface-2);
179 | border: 1px solid var(--border-1);
180 | }
181 |
182 | aside nav a::before {
183 | content: '';
184 | display: block;
185 | width: .45rem;
186 | height: .45rem;
187 | border-radius: 100%;
188 | margin-right: .7rem;
189 | }
190 |
191 | aside nav a.active::before {
192 | background-color: var(--text-1);
193 | }
194 |
195 | aside nav a span {
196 | justify-self: end;
197 | background: var(--yellow-0);
198 | border: 1px solid var(--choco-1);
199 | color: var(--choco-4);
200 | font-family: var(--font-mono);
201 | text-transform: uppercase;
202 | font-size: .6em;
203 | margin-left: 7px;
204 | padding: .25em .7em;
205 | white-space: nowrap;
206 | word-spacing: -2px;
207 | }
208 |
209 | .theme-toggle {
210 | transition: none;
211 | box-shadow: none;
212 | background: none;
213 | border-radius: 0;
214 | border: none;
215 | border-left: 1px solid var(--border-1);
216 | padding-inline: 2rem;
217 | }
218 |
219 | #moon,
220 | #sun {
221 | fill: var(--nav-icon);
222 | stroke: none;
223 | }
224 |
225 | #sun {
226 | transition: transform 0.5s var(--ease-4);
227 | transform-origin: center center;
228 | }
229 |
230 | #sun-beams {
231 | --_opacity-dur: 0.15s;
232 | stroke: var(--nav-icon);
233 | stroke-width: 2px;
234 | transform-origin: center center;
235 | transition:
236 | transform 0.5s var(--ease-elastic-out-4),
237 | opacity var(--_opacity-dur) var(--ease-3);
238 | }
239 |
240 | #sun-beams:hover {
241 | stroke: var(--nav-icon-hover);
242 | }
243 |
244 | #moon > circle {
245 | transition: transform 0.5s var(--ease-out-3);
246 | }
247 |
248 | [data-theme='light'] #sun {
249 | transform: scale(0.5);
250 | }
251 |
252 | [data-theme='light'] #sun-beams {
253 | transform: rotateZ(0.25turn);
254 | --_opacity-dur: 0.5s;
255 | }
256 |
257 | [data-theme='dark'] #moon > circle {
258 | transform: translateX(-20px);
259 | }
260 |
261 | [data-theme='dark'] #sun-beams {
262 | opacity: 0;
263 | }
264 |
--------------------------------------------------------------------------------
/examples/default-provider/app/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/default-provider/app/nav.tsx:
--------------------------------------------------------------------------------
1 | export default function Nav() {
2 | return
3 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | GitHub
30 |
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/examples/default-provider/app/sidebar-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { usePathname } from 'next/navigation'
4 | import Link from 'next/link';
5 |
6 | export default function SidebarNav() {
7 | const pathname = usePathname()
8 |
9 | return
10 |
11 |
12 | Basic example
13 |
14 |
15 | Background video
16 |
17 |
18 | Custom theme
19 |
20 |
21 | Custom player
22 |
23 |
24 | Slotted poster
25 |
26 |
27 | String video source
28 |
29 |
30 | HLS sourceplayer only
31 |
32 |
33 | DASH sourceplayer only
34 |
35 |
36 | MP4 sourceplayer only
37 |
38 |
39 | Source tagplayer only
40 |
41 |
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/examples/default-provider/app/theme-toggle.js:
--------------------------------------------------------------------------------
1 | const getColorPreference = () => {
2 | if (globalThis.localStorage?.getItem('theme-preference')) {
3 | return localStorage.getItem('theme-preference');
4 | } else {
5 | return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
6 | }
7 | };
8 |
9 | const setPreference = () => {
10 | localStorage.setItem('theme-preference', `${theme.value}`);
11 | reflectPreference();
12 | };
13 |
14 | const reflectPreference = () => {
15 | globalThis.document?.firstElementChild?.setAttribute('data-theme', `${theme.value}`);
16 | globalThis.document?.querySelector('#theme-toggle')?.setAttribute('aria-label', `${theme.value}`);
17 | };
18 |
19 | const theme = {
20 | value: getColorPreference(),
21 | };
22 |
23 | reflectPreference();
24 |
25 | globalThis.onload = () => {
26 | reflectPreference();
27 |
28 | document.querySelector('#theme-toggle')?.addEventListener('click', (e) => {
29 | theme.value = theme.value === 'light' ? 'dark' : 'light';
30 | setPreference();
31 | });
32 | };
33 |
34 | globalThis
35 | .matchMedia?.('(prefers-color-scheme: dark)')
36 | .addEventListener('change', ({ matches: isDark }) => {
37 | theme.value = isDark ? 'dark' : 'light';
38 | setPreference();
39 | });
40 |
--------------------------------------------------------------------------------
/examples/default-provider/images/get-started-poster.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/next-video/f14fda2e3e356ea31ea93c4f334bb6cb1eb23d0c/examples/default-provider/images/get-started-poster.jpg
--------------------------------------------------------------------------------
/examples/default-provider/next-video.mjs:
--------------------------------------------------------------------------------
1 | import { NextVideo } from 'next-video/process';
2 | import { readFile } from 'fs/promises';
3 |
4 | export const { GET, POST, handler, withNextVideo } = NextVideo({
5 | loadAsset: async function (assetPath) {
6 | console.warn(99, assetPath);
7 | const file = await readFile(assetPath);
8 | const asset = JSON.parse(file.toString());
9 | return asset;
10 | },
11 | provider: 'mux',
12 | providerConfig: {
13 | mux: {
14 | videoQuality: 'premium',
15 | },
16 | },
17 | });
18 |
--------------------------------------------------------------------------------
/examples/default-provider/next.config.mjs:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { fileURLToPath } from 'node:url';
3 | import { withNextVideo } from 'next-video/process';
4 | // import { withNextVideo } from './next-video.mjs';
5 |
6 | const fileDir = path.dirname(fileURLToPath(import.meta.url));
7 |
8 | /** @type {import('next').NextConfig} */
9 | const nextConfig = (phase, { defaultConfig }) => {
10 | return {
11 | ...defaultConfig,
12 | // Needed for Turbopack and symlinking to work
13 | // https://github.com/vercel/next.js/issues/64472#issuecomment-2077483493
14 | // https://nextjs.org/docs/pages/api-reference/config/next-config-js/output#caveats
15 | outputFileTracingRoot: path.join(fileDir, '../../'),
16 | images: {
17 | remotePatterns: [
18 | {
19 | protocol: 'https',
20 | hostname: 'image.mux.com',
21 | },
22 | ],
23 | },
24 | };
25 | };
26 |
27 | export default withNextVideo(nextConfig, {
28 | provider: 'vercel-blob'
29 | });
30 |
31 | // Amazon S3 example
32 | // export default withNextVideo(nextConfig, {
33 | // provider: 'amazon-s3',
34 | // providerConfig: {
35 | // 'amazon-s3': {
36 | // endpoint: 'https://s3.us-east-1.amazonaws.com',
37 | // }
38 | // },
39 | // });
40 |
--------------------------------------------------------------------------------
/examples/default-provider/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "default-provider",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "next": "15.1.2",
13 | "next-video": "file:../..",
14 | "open-props": "^1.7.8",
15 | "player.style": "^0.1.1",
16 | "react": "^19",
17 | "react-dom": "^19",
18 | "react-player": "^3.0.0-canary.0"
19 | },
20 | "devDependencies": {
21 | "@types/node": "^22",
22 | "@types/react": "^19",
23 | "@types/react-dom": "^19",
24 | "eslint": "^9",
25 | "eslint-config-next": "15.1.2",
26 | "typescript": "^5"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/examples/default-provider/public/country-clouds:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/muxinc/next-video/f14fda2e3e356ea31ea93c4f334bb6cb1eb23d0c/examples/default-provider/public/country-clouds
--------------------------------------------------------------------------------
/examples/default-provider/public/get-started.vtt:
--------------------------------------------------------------------------------
1 | WEBVTT
2 |
3 | 1
4 | 00:00:07.000 --> 00:00:09.000
5 | Video files are big!
6 |
--------------------------------------------------------------------------------
/examples/default-provider/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["video.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/examples/default-provider/video.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/examples/default-provider/videos/country-clouds.mp4.json:
--------------------------------------------------------------------------------
1 | {"status":"ready","originalFilePath":"videos/country-clouds.mp4","provider":"mux","providerMetadata":{"mux":{"uploadId":"RPMSA1MrehzNmKTNbL5m2dikLE9oolO1niXcyOKAdjg","assetId":"CX00rjcZ3NUrbpVcNrofK3uSOjWFroZAS3Xq9E16yH02o","playbackId":"m00b01mJ2BQP4GMYXKoOmgRdnHELCPpYFtIO52h01l9ozY"}},"createdAt":1710978290774,"updatedAt":1710978359041,"size":33682771,"sources":[{"src":"https://stream.mux.com/m00b01mJ2BQP4GMYXKoOmgRdnHELCPpYFtIO52h01l9ozY.m3u8","type":"application/x-mpegURL"}],"poster":"https://image.mux.com/m00b01mJ2BQP4GMYXKoOmgRdnHELCPpYFtIO52h01l9ozY/thumbnail.webp","blurDataURL":"data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAAAQAgCdASoQAAgAAQAcJbACdLoAAwi2bUSAAP74Qu6oOFirJlY8OZVMZBXX2e9f/SRsDS2UEX0Lxo/JvCWyFRzjaBYzny/6POMaoi3hj6+5/8zllIyezfJeEHJ/ROthhAA="}
--------------------------------------------------------------------------------
/examples/default-provider/videos/get-started.mp4.json:
--------------------------------------------------------------------------------
1 | {
2 | "status": "ready",
3 | "originalFilePath": "videos/get-started.mp4",
4 | "provider": "mux",
5 | "providerMetadata": {
6 | "mux": {
7 | "uploadId": "LJeQjU9A1n029JC8Nx2Z71U7wOsdVA02n9mPOL02iYspkY",
8 | "assetId": "3fMT5ZqYShCbiI00Um3K00eGAqf7d6xmylwDKVEYAl4Ck",
9 | "playbackId": "sxY31L6Opl02RWPpm3Gro9XTe7fRHBjs92x93kiB1vpc"
10 | }
11 | },
12 | "createdAt": 1701286882695,
13 | "updatedAt": 1701287023961,
14 | "size": 431539343,
15 | "sources": [{
16 | "src": "https://stream.mux.com/sxY31L6Opl02RWPpm3Gro9XTe7fRHBjs92x93kiB1vpc.m3u8",
17 | "type": "application/x-mpegURL"
18 | }],
19 | "poster": "https://image.mux.com/sxY31L6Opl02RWPpm3Gro9XTe7fRHBjs92x93kiB1vpc/thumbnail.webp",
20 | "blurDataURL": "data:image/webp;base64,UklGRmoAAABXRUJQVlA4IF4AAADwAQCdASoQAAkAAQAcJQBYdiHf4+aniYAA/rhf+Of/jn/f9s/w17lHAXMzsU7AtWj/JSrBFYQkBJ+wm9Yap/vubVh5XRR2E/JmGfSkKof+okNWuAeXs3zQT6tQAAAA"
21 | }
22 |
--------------------------------------------------------------------------------
/examples/default-provider/videos/storage.googleapis.com_muxdemofiles_mux.mp4.json:
--------------------------------------------------------------------------------
1 | {"status":"ready","originalFilePath":"https://storage.googleapis.com/muxdemofiles/mux.mp4","provider":"vercel-blob","providerMetadata":{"vercel-blob":{"key":"videos/mux.mp4","url":"https://p2kzwwhnykpzlhrx.public.blob.vercel-storage.com/videos/mux-BI9p2oskfxfQZU79gqedlQRhwOEGXR.mp4","contentType":"video/mp4"}},"createdAt":1741207347549,"updatedAt":1741207365063,"sources":[{"src":"https://p2kzwwhnykpzlhrx.public.blob.vercel-storage.com/videos/mux-BI9p2oskfxfQZU79gqedlQRhwOEGXR.mp4","type":"video/mp4"}]}
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-video",
3 | "version": "2.2.0",
4 | "type": "module",
5 | "description": "A React component for adding video to your Next.js application. It extends both the video element and your Next app with features for automatic video optimization.",
6 | "author": "Mux Lab ",
7 | "license": "MIT",
8 | "homepage": "https://github.com/muxinc/next-video#readme",
9 | "bugs": {
10 | "url": "https://github.com/muxinc/next-video/issues"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/muxinc/next-video.git"
15 | },
16 | "files": [
17 | "dist",
18 | "video-types"
19 | ],
20 | "bin": {
21 | "next-video": "./dist/cli.js"
22 | },
23 | "main": "./dist/components/video.js",
24 | "exports": {
25 | ".": "./dist/components/video.js",
26 | "./player": "./dist/components/players/default-player.js",
27 | "./process": {
28 | "import": "./dist/process.js",
29 | "require": "./dist/cjs/process.js",
30 | "default": "./dist/process.js"
31 | },
32 | "./background-video": "./dist/components/background-video.js",
33 | "./background-player": "./dist/components/players/background-player.js",
34 | "./request-handler": "./dist/request-handler.js",
35 | "./video-types/*": "./video-types/*.d.ts",
36 | "./dist/cjs/*": "./dist/cjs/*",
37 | "./dist/*": "./dist/*"
38 | },
39 | "typesVersions": {
40 | "*": {
41 | "*": [
42 | "./dist/*"
43 | ]
44 | }
45 | },
46 | "scripts": {
47 | "clean": "rm -rf dist",
48 | "watch": "npm run types:esm -- -w & npm run types:cjs -- -w & npm run build:esm -- --watch=forever & npm run build:cjs -- --watch=forever",
49 | "types": "npm run types:esm & npm run types:cjs",
50 | "types:esm": "tsc --outDir dist",
51 | "types:cjs": "tsc --outDir dist/cjs",
52 | "prebuild": "npm run clean && npm run types",
53 | "build": "npm run build:esm && npm run build:cjs",
54 | "build:esm": "esbuild \"src/**/*.ts*\" --outdir=dist --format=esm --target=es2020",
55 | "build:cjs": "esbuild \"src/**/*.ts*\" --outdir=dist/cjs --platform=node --format=cjs --target=es2020 --define:import.meta.url=\\\"\\\"",
56 | "postbuild:cjs": "node --eval \"fs.writeFileSync('./dist/cjs/package.json', '{\\\"type\\\": \\\"commonjs\\\"}')\"",
57 | "prepare": "npm run build",
58 | "cli": "node --loader tsx --no-warnings ./src/cli",
59 | "test": "glob -c \"c8 --src src --exclude 'next.config.js' --exclude 'tests/**' --exclude 'dist/**' node --loader tsx --no-warnings --test\" \"./tests/**/*.test.{ts,tsx}\"",
60 | "coverage": "c8 report --reporter=text-lcov > ./coverage/lcov.info"
61 | },
62 | "peerDependencies": {
63 | "@types/react": "^17.0.0 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0",
64 | "next": ">=12.0.0",
65 | "react": "^17.0.2 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0",
66 | "react-dom": "^17.0.2 || ^17.0.2-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0"
67 | },
68 | "peerDependenciesMeta": {
69 | "@types/react": {
70 | "optional": true
71 | },
72 | "@types/react-dom": {
73 | "optional": true
74 | }
75 | },
76 | "devDependencies": {
77 | "@types/dotenv-flow": "^3.3.3",
78 | "@types/node": "^22.10.2",
79 | "@types/react": "19.0.2",
80 | "@types/resolve": "^1.20.6",
81 | "@types/yargs": "^17.0.33",
82 | "c8": "^10.1.3",
83 | "esbuild": "^0.24.2",
84 | "glob": "^11.0.0",
85 | "next": "^15.1.2",
86 | "react": "^19.0.0",
87 | "react-test-renderer": "^19.0.0",
88 | "tsx": "3.13.0",
89 | "typescript": "^5.7.2"
90 | },
91 | "dependencies": {
92 | "@aws-sdk/client-s3": "^3.717.0",
93 | "@inquirer/prompts": "^4.3.1",
94 | "@mux/mux-node": "9.0.1",
95 | "@mux/mux-video": "^0.24.2",
96 | "@mux/playback-core": "^0.28.2",
97 | "@next/env": "^15.1.2",
98 | "@paralleldrive/cuid2": "^2.2.2",
99 | "@vercel/blob": "^0.27.0",
100 | "chalk": "^4.1.2",
101 | "chokidar": "^4.0.3",
102 | "dash-video-element": "^0.1.0",
103 | "hls-video-element": "^1.4.1",
104 | "magicast": "^0.3.5",
105 | "media-chrome": "^4.4.0",
106 | "player.style": "^0.1.3",
107 | "resolve": "^1.22.10",
108 | "symlink-dir": "^6.0.3",
109 | "undici": "^5.28.4",
110 | "yargs": "^17.7.2"
111 | },
112 | "prettier": {
113 | "printWidth": 120,
114 | "singleQuote": true,
115 | "tabWidth": 2,
116 | "useTabs": false,
117 | "semi": true,
118 | "quoteProps": "as-needed",
119 | "jsxSingleQuote": false,
120 | "trailingComma": "es5",
121 | "bracketSpacing": true,
122 | "arrowParens": "always"
123 | },
124 | "keywords": [
125 | "next",
126 | "nextjs",
127 | "react",
128 | "video",
129 | "video-streaming",
130 | "video-processing",
131 | "audio",
132 | "media",
133 | "player"
134 | ]
135 | }
136 |
--------------------------------------------------------------------------------
/src/assets.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'node:path';
2 | import { cwd } from 'node:process';
3 | import { stat } from 'node:fs/promises';
4 | import { getVideoConfig } from './config.js';
5 | import { deepMerge, camelCase, isRemote, toSafePath } from './utils/utils.js';
6 | import * as transformers from './providers/transformers.js';
7 |
8 | export interface Asset {
9 | status:
10 | | 'sourced'
11 | | 'pending'
12 | | 'uploading'
13 | | 'processing'
14 | | 'ready'
15 | | 'error';
16 | originalFilePath: string;
17 | // TODO: should we add a `filePath` field which would store the file path
18 | // without the configurable folder? This would allow us to change the folder
19 | // without having to update the file paths in the assets.
20 | // filePath?: string;
21 | provider: string;
22 | providerMetadata?: {
23 | [provider: string]: { [key: string]: any };
24 | };
25 | poster?: string;
26 | sources?: AssetSource[];
27 | blurDataURL?: string;
28 | size?: number;
29 | error?: any;
30 | createdAt: number;
31 | updatedAt: number;
32 |
33 | // Here for backwards compatibility with older assets.
34 | externalIds?: {
35 | [key: string]: string; // { uploadId, playbackId, assetId }
36 | };
37 |
38 | // Allow any other properties to be added to the asset so properties like
39 | // `thumbnailTime` can be added to the asset after the client-side transform.
40 | [x: string]: unknown;
41 | }
42 |
43 | export interface AssetSource {
44 | src: string;
45 | type?: string;
46 | }
47 |
48 | export async function getAsset(filePath: string): Promise {
49 | const videoConfig = await getVideoConfig();
50 | const assetConfigPath = await getAssetConfigPath(filePath);
51 | return videoConfig.loadAsset(assetConfigPath)
52 | }
53 |
54 | export async function getAssetConfigPath(filePath: string) {
55 | return `${await getAssetPath(filePath)}.json`;
56 | }
57 |
58 | async function getAssetPath(filePath: string) {
59 | if (!isRemote(filePath)) return filePath;
60 |
61 | const { folder, remoteSourceAssetPath = defaultRemoteSourceAssetPath } =
62 | await getVideoConfig();
63 |
64 | if (!folder) throw new Error('Missing video `folder` config.');
65 |
66 | // Add the asset directory and make remote url a safe file path.
67 | return path.join(folder, remoteSourceAssetPath(filePath));
68 | }
69 |
70 | function defaultRemoteSourceAssetPath(url: string) {
71 | const urlObj = new URL(url);
72 | // Strip the https from the asset path.
73 | // Strip the search params from the file path so in most cases it'll
74 | // have a video file extension and not a query string in the end.
75 | return toSafePath(decodeURIComponent(`${urlObj.hostname}${urlObj.pathname}`));
76 | }
77 |
78 | export async function createAsset(
79 | filePath: string,
80 | assetDetails?: Partial
81 | ) {
82 | const videoConfig = await getVideoConfig();
83 | const assetConfigPath = await getAssetConfigPath(filePath);
84 |
85 | let originalFilePath = filePath;
86 | if (!isRemote(filePath)) {
87 | originalFilePath = path.relative(cwd(), filePath);
88 | }
89 |
90 | const newAssetDetails: Asset = {
91 | status: 'pending', // overwritable
92 | ...assetDetails,
93 | originalFilePath,
94 | provider: videoConfig.provider,
95 | providerMetadata: {},
96 | createdAt: Date.now(),
97 | updatedAt: Date.now(),
98 | };
99 |
100 | if (!isRemote(filePath)) {
101 | try {
102 | newAssetDetails.size = (await stat(filePath))?.size;
103 | } catch {
104 | // Ignore error.
105 | }
106 | }
107 |
108 | await videoConfig.saveAsset(assetConfigPath, newAssetDetails)
109 |
110 | return newAssetDetails;
111 | }
112 |
113 | export async function updateAsset(
114 | filePath: string,
115 | assetDetails: Partial
116 | ) {
117 | const videoConfig = await getVideoConfig();
118 | const assetConfigPath = await getAssetConfigPath(filePath);
119 | const currentAsset = await getAsset(filePath);
120 |
121 | if (!currentAsset) {
122 | throw new Error(`Asset not found: ${filePath}`);
123 | }
124 |
125 | let newAssetDetails = deepMerge(currentAsset, assetDetails, {
126 | updatedAt: Date.now(),
127 | }) as Asset;
128 |
129 | newAssetDetails = transformAsset(transformers, newAssetDetails);
130 |
131 | await videoConfig.updateAsset(assetConfigPath, newAssetDetails)
132 |
133 | return newAssetDetails;
134 | }
135 |
136 | type TransformerRecord = Record<
137 | string,
138 | {
139 | transform: (asset: Asset, props?: any) => Asset;
140 | }
141 | >;
142 |
143 | function transformAsset(transformers: TransformerRecord, asset: Asset) {
144 | const provider = asset.provider;
145 | if (!provider) return asset;
146 |
147 | for (let [key, transformer] of Object.entries(transformers)) {
148 | if (key === camelCase(provider)) {
149 | return transformer.transform(asset);
150 | }
151 | }
152 |
153 | return asset;
154 | }
155 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import process from 'node:process';
3 | import nextEnv from '@next/env';
4 | import log from './utils/logger.js';
5 | import yargs from 'yargs/yargs';
6 |
7 | import * as init from './cli/init.js';
8 | import * as sync from './cli/sync.js';
9 |
10 | nextEnv.loadEnvConfig(process.cwd(), undefined, log);
11 |
12 | yargs(process.argv.slice(2)).command(init).command(sync).demandCommand().help().argv;
13 |
--------------------------------------------------------------------------------
/src/cli/lib/json-configs.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises';
2 |
3 | import { PACKAGE_NAME } from '../../constants.js';
4 |
5 | export function updateTSConfigFileContent(tsContents: string) {
6 | const newItem = 'video.d.ts';
7 |
8 | // Regex to find "include" array
9 | const includeRegex = /("include"\s*:\s*\[)([^\]]*?)(\])/;
10 |
11 | // Function to add "video.d.ts" to the "include" array
12 | const addVideoDts = (_match: string, p1: string, p2: string, p3: string) => {
13 | const trimmedContent = p2.trim();
14 |
15 | // Check if the array is multiline or inline
16 | if (/\r?\n/.test(p2)) {
17 | // Get the whitespace in front of the first item in the array
18 | const whitespace = p2.match(/^\s*/)?.[0] || '';
19 |
20 | // Multiline array
21 | return `${p1}${whitespace}"${newItem}",${p2}${p3}`;
22 | } else {
23 | // Inline array
24 | return `${p1}"${newItem}", ${trimmedContent ? `${trimmedContent}` : ''}${p3}`;
25 | }
26 | };
27 |
28 | // Update the tsconfig text
29 | const updatedContents = tsContents.replace(includeRegex, addVideoDts);
30 |
31 | // check if the JSON is valid before we write it back. It's ok if blows up
32 | // and we'll just catch/let the user know below.
33 | JSON.parse(updatedContents);
34 |
35 | return updatedContents;
36 | }
37 |
38 | export async function checkPackageJsonForNextVideo(packagePath: string = './package.json') {
39 | const pkg = await readFile(packagePath, 'utf-8');
40 |
41 | const json = JSON.parse(pkg);
42 |
43 | return !!(json.devDependencies?.[PACKAGE_NAME] || json.dependencies?.[PACKAGE_NAME]);
44 | }
45 |
--------------------------------------------------------------------------------
/src/cli/lib/next-config.ts:
--------------------------------------------------------------------------------
1 | import { builders, parseModule, loadFile, generateCode, writeFile } from 'magicast';
2 |
3 | import fs from 'node:fs/promises';
4 | import path from 'node:path';
5 |
6 | import { PACKAGE_NAME } from '../../constants.js';
7 | import { videoConfigDefault } from '../../config.js';
8 | import type { VideoConfig } from '../../config.js';
9 |
10 | function extensionToType(filePath: string) {
11 | if (filePath.endsWith('.mjs') || filePath.endsWith('.ts')) {
12 | return 'module';
13 | }
14 |
15 | return 'commonjs';
16 | }
17 |
18 | export default async function updateNextConfigFile(parentDir: string = './', videoConfig?: VideoConfig) {
19 | let type: 'module' | 'commonjs' = 'commonjs';
20 | let configPath: string | undefined = undefined;
21 | let configContents: string = '';
22 |
23 | const pathsToCheck = ['next.config.js', 'next.config.mjs', 'next.config.ts'];
24 |
25 | for (let i = 0; i < pathsToCheck.length; i++) {
26 | const filePath = path.join(parentDir, pathsToCheck[i]);
27 | let exists;
28 | try {
29 | exists = await fs.stat(filePath);
30 | } catch (e) {
31 | exists = false;
32 | }
33 |
34 | if (exists) {
35 | type = extensionToType(pathsToCheck[i]);
36 | configPath = filePath;
37 | configContents = await fs.readFile(filePath, 'utf-8');
38 |
39 | break;
40 | }
41 | }
42 |
43 | if (!configPath) {
44 | throw { error: 'not_found' };
45 | }
46 |
47 | if (configContents.includes(PACKAGE_NAME)) {
48 | throw { error: 'already_added' };
49 | }
50 |
51 | if (type === 'commonjs') {
52 | const mod = parseModule(configContents);
53 |
54 | // @ts-ignore
55 | const body = mod?.$ast?.body
56 | // Iterate from bottom to top.
57 | let i = body.length ?? 0;
58 | while (i--) {
59 | const node = body[i];
60 | // Replace `module.exports = something;` with `module.exports = withNextVideo(something);`
61 | if (node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression') {
62 | const { left, right } = node.expression ?? {};
63 | if (left.type === 'MemberExpression' && left.object.type === 'Identifier' && left.object.name === 'module') {
64 | if (left.property.type === 'Identifier' && left.property.name === 'exports') {
65 | if (right.type === 'Identifier') {
66 | const expressionToWrap = generateCode(right).code;
67 | node.expression.right = wrapWithNextVideo(expressionToWrap, videoConfig).$ast;
68 | }
69 | }
70 | }
71 | }
72 | }
73 |
74 | let code =
75 | `const { withNextVideo } = require('${path.posix.join(PACKAGE_NAME, 'process')}')
76 |
77 | ${generateCode(mod).code}
78 | `;
79 |
80 | // @ts-ignore
81 | await fs.writeFile(configPath, code);
82 |
83 | return { type, configPath };
84 | }
85 |
86 | if (type === 'module') {
87 | const mod = await loadFile(configPath);
88 |
89 | mod.imports.$add({
90 | from: path.posix.join(PACKAGE_NAME, 'process'),
91 | imported: 'withNextVideo',
92 | local: 'withNextVideo',
93 | });
94 |
95 | const expressionToWrap = generateCode(mod.exports.default.$ast).code;
96 | mod.exports.default = wrapWithNextVideo(expressionToWrap, videoConfig);
97 |
98 | // @ts-ignore
99 | writeFile(mod, configPath);
100 |
101 | return { type, configPath };
102 | }
103 | }
104 |
105 | function wrapWithNextVideo(expressionToWrap: string, videoConfig?: VideoConfig) {
106 | if (videoConfig?.folder && videoConfig.folder !== videoConfigDefault.folder) {
107 | return builders.raw(`withNextVideo(${expressionToWrap}, { folder: '${videoConfig.folder}' })`);
108 | } else {
109 | return builders.raw(`withNextVideo(${expressionToWrap})`);
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/cli/sync.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import chokidar from 'chokidar';
3 | import { Argv, Arguments } from 'yargs';
4 |
5 | import { cwd } from 'node:process';
6 | import { readdir } from 'node:fs/promises';
7 | import path from 'node:path';
8 |
9 | import { callHandler } from '../process.js';
10 | import { createAsset, getAsset } from '../assets.js';
11 | import { getVideoConfig } from '../config.js';
12 | import { getPackageVersion } from '../utils/utils.js';
13 | import log from '../utils/logger.js';
14 |
15 | export const command = 'sync';
16 | export const desc =
17 | 'Checks for new video files in the videos directory, uploads them, and checks any existing assets for updates.';
18 |
19 | export function builder(yargs: Argv) {
20 | return yargs.options({
21 | dir: {
22 | alias: 'd',
23 | describe: 'The directory you initialized next-video with.',
24 | type: 'string',
25 | default: 'videos',
26 | },
27 | watch: {
28 | alias: 'w',
29 | describe: 'Watch the videos directory for changes.',
30 | type: 'boolean',
31 | default: false,
32 | },
33 | });
34 | }
35 |
36 | function watcher(dir: string) {
37 | const watcher = chokidar.watch(dir, {
38 | ignored: /(^|[\/\\])\..*|\.json$/,
39 | persistent: true,
40 | });
41 |
42 | watcher.on('add', async (filePath) => {
43 | try {
44 | await getAsset(filePath);
45 | } catch {
46 | const newAsset = await createAsset(filePath);
47 | if (newAsset) {
48 | log.add(`New file found: ${filePath}`);
49 | const videoConfig = await getVideoConfig();
50 | return callHandler('local.video.added', newAsset, videoConfig);
51 | }
52 | }
53 | });
54 | }
55 |
56 | export async function handler(argv: Arguments) {
57 | const directoryPath = path.join(cwd(), argv.dir as string);
58 |
59 | const version = getPackageVersion('next-video');
60 | log.space(log.label(`▶︎ next-video ${version}`));
61 | log.space();
62 |
63 | try {
64 | // Filter out directories and get relative file paths.
65 | const files = (await getFiles(directoryPath))
66 | .map((file) => path.relative(directoryPath, file));
67 |
68 | const jsonFiles = files.filter((file) => file.endsWith('.json'));
69 | const otherFiles = files.filter(
70 | (file) => !file.match(/(^|[\/\\])\..*|\.json$/)
71 | );
72 |
73 | const newFileProcessor = async (file: string) => {
74 | log.info(log.label('Processing file:'), file);
75 |
76 | const filePath = path.join(directoryPath, file);
77 | const newAsset = await createAsset(filePath);
78 |
79 | if (newAsset) {
80 | const videoConfig = await getVideoConfig();
81 | return callHandler('local.video.added', newAsset, videoConfig);
82 | }
83 | };
84 |
85 | const existingFileProcessor = async (file: string) => {
86 | const filePath = path.join(directoryPath, file);
87 | const parsedPath = path.parse(filePath);
88 | const assetPath = path.join(parsedPath.dir, parsedPath.name);
89 | const existingAsset = await getAsset(assetPath);
90 |
91 | // If the existing asset is 'pending', 'uploading', or 'processing', run
92 | // it back through the local video handler.
93 | const assetStatus = existingAsset?.status;
94 |
95 | if (
96 | assetStatus &&
97 | ['sourced', 'pending', 'uploading', 'processing'].includes(assetStatus)
98 | ) {
99 | const videoConfig = await getVideoConfig();
100 | return callHandler('local.video.added', existingAsset, videoConfig);
101 | }
102 | };
103 |
104 | const unprocessedFilter = (file: string) => {
105 | const jsonFile = `${file}.json`;
106 | return !jsonFiles.includes(jsonFile);
107 | };
108 |
109 | const unprocessedVideos = otherFiles.filter(unprocessedFilter);
110 |
111 | if (unprocessedVideos.length > 0) {
112 | const s = unprocessedVideos.length === 1 ? '' : 's';
113 | log.add(`Found ${unprocessedVideos.length} unprocessed video${s}`);
114 | }
115 |
116 | const processing = await Promise.all([
117 | ...unprocessedVideos.map(newFileProcessor),
118 | ...jsonFiles.map(existingFileProcessor),
119 | ]);
120 |
121 | const processed = processing.flat().filter((asset) => asset);
122 |
123 | if (processed.length > 0) {
124 | const s = processed.length === 1 ? '' : 's';
125 | log.success(
126 | `Processed (or resumed processing) ${processed.length} video${s}`
127 | );
128 | } else {
129 | log.info('No new or unprocessed videos found');
130 | }
131 |
132 | if (argv.watch) {
133 | const relativePath = path.relative(cwd(), directoryPath);
134 | log.info(`Watching for file changes in ./${relativePath}`);
135 | log.space();
136 | watcher(directoryPath);
137 | }
138 |
139 | } catch (err: any) {
140 | if (err.code === 'ENOENT' && err.path === directoryPath) {
141 | log.warning(`Directory does not exist: ${directoryPath}`);
142 | log.info(
143 | `Did you forget to run ${chalk.bold.magenta('next-video init')}? You can also use the ${chalk.bold(
144 | '--dir'
145 | )} flag to specify a different directory.`
146 | );
147 | return;
148 | }
149 |
150 | if (err.code === 'ENOENT') {
151 | log.warning(`Source video file does not exist: ${err.path}`);
152 | return;
153 | }
154 | if(err.message.includes("MUX_TOKEN_ID environment variable is missing or empty") || err.message.includes("MUX_TOKEN_SECRET environment variable is missing or empty")){
155 | log.error(`Mux MUX_TOKEN_ID or MUX_TOKEN_SECRET can't be found. Visit \x1b[4;34mhttps://next-video.dev/docs#remote-storage-and-optimization\x1b[0m for more information.`);
156 | return;
157 | }
158 |
159 | log.error('An unknown error occurred', err);
160 | }
161 | }
162 |
163 | async function getFiles(dir: string): Promise {
164 | const dirents = await readdir(dir, { withFileTypes: true });
165 |
166 | const files = await Promise.all(dirents.map((dirent) => {
167 | const res = path.resolve(dir, dirent.name);
168 | return dirent.isDirectory() ? getFiles(res) : res;
169 | }));
170 | return files.flat();
171 | }
172 |
--------------------------------------------------------------------------------
/src/components/alert.tsx:
--------------------------------------------------------------------------------
1 | interface AlertProps {
2 | status?: string
3 | hidden?: boolean
4 | }
5 |
6 | export function Alert({ status, hidden }: AlertProps) {
7 |
8 | let title: string = '';
9 | let message: string = '';
10 |
11 | switch (status) {
12 | case 'error':
13 | title = 'Error';
14 | message = 'An error occurred while uploading your video. Please check the CLI logs for more info.';
15 | break;
16 | case 'sourced':
17 | title = 'Video is not processing';
18 | message = 'Make sure to run next-video sync. The currently loaded video is the source file.';
19 | break;
20 | default:
21 | title = 'Upload in progress...';
22 | message = 'Your video file is being uploaded. The currently loaded video is the source file.';
23 | break;
24 | }
25 |
26 | return (
27 | <>
28 |
70 |
71 | {status === 'error'
72 | ?
73 | :
}
74 |
{title}
75 |
{message}
76 |
77 | >
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/background-video.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { forwardRef } from 'react';
4 |
5 | import BackgroundPlayer from './players/background-player.js';
6 | import Video from './video.js';
7 |
8 | import type { VideoProps } from './types.js';
9 | export type * from './types.js';
10 |
11 | const BackgroundVideo = forwardRef((props, forwardedRef) => {
12 | return ;
18 | });
19 |
20 | export default BackgroundVideo;
21 |
--------------------------------------------------------------------------------
/src/components/players/background-player.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { forwardRef, Children, isValidElement, useState, Suspense } from 'react';
4 | import Media from './media/index.js';
5 | import { getPlaybackId, getPosterURLFromPlaybackId } from '../../providers/mux/transformer.js';
6 | import { svgBlurImage } from '../utils.js';
7 |
8 | import type { PlayerProps } from '../types.js';
9 | import type { MediaProps } from './media/index.js';
10 |
11 | const BackgroundPlayer = forwardRef & PlayerProps>(
12 | (allProps, forwardedRef) => {
13 | let { style, className, children, asset, poster, blurDataURL, onPlaying, onLoadStart, ...rest } = allProps;
14 |
15 | const slottedPoster = Children.toArray(children).find((child) => {
16 | return typeof child === 'object' && 'type' in child && (child.props as any).slot === 'poster';
17 | });
18 |
19 | // If there's a slotted poster image (e.g. next/image) remove the default player poster and blurDataURL.
20 | if (isValidElement(slottedPoster)) {
21 | poster = '';
22 | blurDataURL = undefined;
23 | }
24 |
25 | const props = rest as MediaProps & { thumbnailTime?: number };
26 | const imgStyleProps: React.CSSProperties = {};
27 | const playbackId = asset ? getPlaybackId(asset) : undefined;
28 |
29 | let isCustomPoster = true;
30 | let srcSet: string | undefined;
31 |
32 | if (playbackId && asset?.status === 'ready') {
33 | props.src = undefined;
34 | props.playbackId = playbackId;
35 |
36 | if (poster) {
37 | isCustomPoster = poster !== getPosterURLFromPlaybackId(playbackId, props);
38 |
39 | if (!isCustomPoster) {
40 | // If it's not a custom poster URL, optimize with a srcset.
41 | srcSet =
42 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 480 })} 480w,` +
43 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 640 })} 640w,` +
44 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 960 })} 960w,` +
45 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 1280 })} 1280w,` +
46 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 1600 })} 1600w,` +
47 | `${getPosterURLFromPlaybackId(playbackId, { ...props })} 1920w`;
48 | }
49 | }
50 | }
51 |
52 | if (blurDataURL) {
53 | const showGeneratedBlur = !isCustomPoster && blurDataURL === asset?.blurDataURL;
54 | const showCustomBlur = isCustomPoster && blurDataURL !== asset?.blurDataURL;
55 |
56 | if (showGeneratedBlur || showCustomBlur) {
57 | imgStyleProps.width = '100%';
58 | imgStyleProps.height = '100%';
59 | imgStyleProps.color = 'transparent';
60 | imgStyleProps.backgroundSize = 'cover';
61 | imgStyleProps.backgroundPosition = 'center';
62 | imgStyleProps.backgroundRepeat = 'no-repeat';
63 | imgStyleProps.backgroundImage = `url('data:image/svg+xml;charset=utf-8,${svgBlurImage(blurDataURL)}')`;
64 | }
65 | }
66 |
67 | // Remove props that are not supported by MuxVideo.
68 | delete props.thumbnailTime;
69 | const [posterHidden, setPosterHidden] = useState(false);
70 |
71 | return (
72 |
73 | <>
74 |
112 |
113 |
{
118 | onPlaying?.(event as any);
119 | setPosterHidden(true);
120 | }}
121 | onLoadStart={(event) => {
122 | onLoadStart?.(event as any);
123 | setPosterHidden(false);
124 | }}
125 | muted={true}
126 | autoPlay={true}
127 | loop={true}
128 | playsInline={true}
129 | {...props}
130 | />
131 | {poster && (
132 |
141 | )}
142 | {children}
143 |
144 | >
145 |
146 | );
147 | }
148 | );
149 |
150 | export default BackgroundPlayer;
151 |
--------------------------------------------------------------------------------
/src/components/players/default-player.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { forwardRef, Suspense, Children, isValidElement } from 'react';
4 | import Sutro from 'player.style/sutro/react';
5 | import { getPlaybackId, getPosterURLFromPlaybackId } from '../../providers/mux/transformer.js';
6 | import { svgBlurImage } from '../utils.js';
7 | import Media from './media/index.js';
8 |
9 | import type { MediaProps } from './media/index.js';
10 | import type { PlayerProps } from '../types.js';
11 |
12 | const DefaultPlayer = forwardRef & PlayerProps>((allProps, forwardedRef) => {
13 | let {
14 | style,
15 | children,
16 | asset,
17 | controls = true,
18 | poster,
19 | blurDataURL,
20 | theme: Theme = Sutro,
21 | ...rest
22 | } = allProps;
23 |
24 | const slottedPoster = Children.toArray(children).find((child) => {
25 | return typeof child === 'object' && 'type' in child && (child.props as any).slot === 'poster';
26 | });
27 |
28 | let slottedPosterImg;
29 |
30 | // If there's a slotted poster image (e.g. next/image) remove the default player poster and blurDataURL.
31 | if (isValidElement(slottedPoster)) {
32 | poster = '';
33 | blurDataURL = undefined;
34 |
35 | slottedPosterImg = slottedPoster;
36 | children = Children.toArray(children).filter((child) => child !== slottedPoster);
37 | }
38 |
39 | const props = rest as MediaProps & { thumbnailTime?: number };
40 | const imgStyleProps: React.CSSProperties = {};
41 | const playbackId = asset ? getPlaybackId(asset) : undefined;
42 |
43 | let isCustomPoster = true;
44 | let srcSet: string | undefined;
45 |
46 | if (playbackId && asset?.status === 'ready') {
47 | props.src = undefined;
48 | props.playbackId = playbackId;
49 |
50 | if (poster) {
51 | isCustomPoster = poster !== getPosterURLFromPlaybackId(playbackId, props);
52 |
53 | if (!isCustomPoster) {
54 | // If it's not a custom poster URL, optimize with a srcset.
55 | srcSet =
56 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 480 })} 480w,` +
57 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 640 })} 640w,` +
58 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 960 })} 960w,` +
59 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 1280 })} 1280w,` +
60 | `${getPosterURLFromPlaybackId(playbackId, { ...props, width: 1600 })} 1600w,` +
61 | `${getPosterURLFromPlaybackId(playbackId, { ...props })} 1920w`;
62 | }
63 | }
64 | }
65 |
66 | if (blurDataURL) {
67 | const showGeneratedBlur = !isCustomPoster && blurDataURL === asset?.blurDataURL;
68 | const showCustomBlur = isCustomPoster && blurDataURL !== asset?.blurDataURL;
69 |
70 | if (showGeneratedBlur || showCustomBlur) {
71 | imgStyleProps.gridArea = '1/1';
72 | imgStyleProps.width = '100%';
73 | imgStyleProps.height = '100%';
74 | imgStyleProps.color = 'transparent';
75 | imgStyleProps.backgroundSize = 'cover';
76 | imgStyleProps.backgroundPosition = 'center';
77 | imgStyleProps.backgroundRepeat = 'no-repeat';
78 | imgStyleProps.backgroundImage = `url('data:image/svg+xml;charset=utf-8,${svgBlurImage(blurDataURL)}')`;
79 | }
80 | }
81 |
82 | // Remove props that are not supported by MuxVideo.
83 | delete props.thumbnailTime;
84 |
85 | if (controls && Theme) {
86 | // @ts-ignore
87 | const dataNextVideo = props['data-next-video'];
88 |
89 | // The Mux player supports a poster image slot which improves the loading speed.
90 | if (poster) {
91 | slottedPosterImg = (
92 |
100 | );
101 | poster = '';
102 | }
103 |
104 | return (
105 |
109 | {slottedPosterImg}
110 |
111 |
122 | {playbackId && (
123 |
129 | )}
130 | {children}
131 |
132 |
133 |
134 | );
135 | }
136 |
137 | return (
138 |
139 |
151 | {playbackId && (
152 |
158 | )}
159 | {children}
160 |
161 |
162 | );
163 | });
164 |
165 | export default DefaultPlayer;
166 |
--------------------------------------------------------------------------------
/src/components/players/media/index.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, lazy } from 'react';
2 | import { getUrlExtension } from '../../utils.js';
3 | import type { MuxMediaProps } from '@mux/playback-core';
4 | import type MuxVideoComponent from '@mux/mux-video/react';
5 | import type HlsVideoComponent from 'hls-video-element/react';
6 | import type DashVideoComponent from 'dash-video-element/react';
7 |
8 | export type MuxVideoProps = Omit & Omit, 'autoPlay'>;
9 |
10 | export type NativeVideoProps = Omit, 'preload' | 'width' | 'height'>;
11 |
12 | export type MediaProps = TPlaybackId extends string
13 | ? MuxVideoProps & { playbackId?: TPlaybackId }
14 | : NativeVideoProps & { playbackId?: undefined };
15 |
16 | let MuxVideo: React.LazyExoticComponent;
17 | let HlsVideo: React.LazyExoticComponent;
18 | let DashVideo: React.LazyExoticComponent;
19 |
20 | const Media = forwardRef((props, forwardedRef) => {
21 |
22 | if (typeof props.playbackId === 'string') {
23 | MuxVideo ??= lazy(() => import('@mux/mux-video/react'));
24 | // @ts-expect-error
25 | return ;
26 | }
27 |
28 | const fileExtension = getUrlExtension(props.src);
29 |
30 | if (fileExtension === 'm3u8') {
31 | HlsVideo ??= lazy(() => import('hls-video-element/react'));
32 | return ;
33 | }
34 |
35 | if (fileExtension === 'mpd') {
36 | DashVideo ??= lazy(() => import('dash-video-element/react'));
37 | return ;
38 | }
39 |
40 | return ;
41 | });
42 |
43 | export default Media;
44 |
--------------------------------------------------------------------------------
/src/components/types.ts:
--------------------------------------------------------------------------------
1 | import type { VideoConfig } from '../config.js';
2 | import type { Asset } from '../assets.js';
3 | import { StaticImageData } from 'next/image.js';
4 | import type { MediaProps, NativeVideoProps, MuxVideoProps } from './players/media/index.js';
5 |
6 | declare module 'react' {
7 | interface CSSProperties {
8 | [key: `--${string}`]: any;
9 | }
10 | }
11 |
12 | export type VideoLoader = (p: VideoLoaderProps) => Promise;
13 | export type VideoLoaderWithConfig = (p: VideoLoaderPropsWithConfig) => Promise;
14 |
15 | export interface VideoLoaderProps {
16 | src?: string;
17 | width?: number;
18 | height?: number;
19 | }
20 |
21 | export type VideoLoaderPropsWithConfig = VideoLoaderProps & {
22 | config: Readonly
23 | }
24 |
25 | export interface PosterProps {
26 | token?: string;
27 | thumbnailTime?: number;
28 | width?: number;
29 | domain?: string;
30 | }
31 |
32 | type DefaultPlayerProps = Omit & PlayerProps;
33 |
34 | // Keep in mind the hierarchy is Video(Player(Media))
35 |
36 | export type VideoProps = TPlaybackId extends string
37 | ? Omit & PlayerProps, 'src' | 'poster'> & SuperVideoProps & { playbackId?: TPlaybackId }
38 | : Omit & SuperVideoProps & { playbackId?: undefined };
39 |
40 | type SuperVideoProps = {
41 | /**
42 | * The component type to render the video as.
43 | */
44 | as?: React.FunctionComponent;
45 |
46 | /**
47 | * An imported video source object or a string video source URL.
48 | * Can be a local or remote video file.
49 | * If it's a string be sure to create an API endpoint to handle the request.
50 | */
51 | src?: Asset | string;
52 |
53 | /**
54 | * The poster image for the video.
55 | */
56 | poster?: StaticImageData | string;
57 |
58 | /**
59 | * Give a fixed width to the video.
60 | */
61 | width?: number;
62 |
63 | /**
64 | * Give a fixed height to the video.
65 | */
66 | height?: number;
67 |
68 | /**
69 | * Set to false to hide the video controls.
70 | */
71 | controls?: boolean;
72 |
73 | /**
74 | * Set a manual data URL to be used as a placeholder image before the poster image successfully loads.
75 | * For imported videos this will be automatically generated.
76 | */
77 | blurDataURL?: string;
78 |
79 | /**
80 | * For best image loading performance the user should provide the sizes attribute.
81 | * The width of the image in the webpage. e.g. sizes="800px". Defaults to 100vw.
82 | * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes
83 | */
84 | sizes?: string;
85 |
86 | /**
87 | * A custom function used to resolve string based video URLs (not imports).
88 | */
89 | loader?: VideoLoader;
90 |
91 | /**
92 | * A custom function to transform the asset object (src and poster).
93 | */
94 | transform?: (asset: Asset) => Asset;
95 | }
96 |
97 | export type VideoPropsInternal = VideoProps & {
98 | /**
99 | * The component type to render the video as.
100 | */
101 | as: React.FunctionComponent;
102 |
103 | /**
104 | * A custom function used to resolve string based video URLs (not imports).
105 | */
106 | loader: VideoLoader;
107 |
108 | /**
109 | * A custom function to transform the asset object (src and poster).
110 | */
111 | transform: (asset: Asset, props?: Record) => Asset;
112 | }
113 |
114 | export type PlayerProps = {
115 | /**
116 | * The asset object for the video.
117 | */
118 | asset?: Asset;
119 |
120 | /**
121 | * A string video source URL.
122 | */
123 | src?: string | undefined;
124 |
125 | /**
126 | * Set to false to hide the video controls.
127 | */
128 | controls?: boolean;
129 |
130 | /**
131 | * The poster image for the video.
132 | */
133 | poster?: StaticImageData | string;
134 |
135 | /**
136 | * Set a manual data URL to be used as a placeholder image before the poster image successfully loads.
137 | * For imported videos this will be automatically generated.
138 | */
139 | blurDataURL?: string;
140 |
141 | /**
142 | * The thumbnail time in seconds to use for the video poster image.
143 | */
144 | thumbnailTime?: number;
145 |
146 | /**
147 | * Change the look and feel with a custom theme.
148 | */
149 | theme?: React.ComponentType;
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/utils.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useCallback } from 'react';
2 |
3 | export const config = JSON.parse(
4 | process.env.NEXT_PUBLIC_DEV_VIDEO_OPTS ??
5 | process.env.NEXT_PUBLIC_VIDEO_OPTS ??
6 | '{}'
7 | );
8 |
9 | const DEFAULT_POLLING_INTERVAL = 5000;
10 | const FILES_FOLDER = `${config.folder ?? 'videos'}/`;
11 |
12 | export function toSymlinkPath(path?: string) {
13 | if (!path?.startsWith(FILES_FOLDER)) return path;
14 | return path?.replace(FILES_FOLDER, `_next-video/`);
15 | }
16 |
17 | export function camelCase(name: string) {
18 | return name.toLowerCase().replace(/[-_]([a-z])/g, ($0, $1) => $1.toUpperCase());
19 | }
20 |
21 | export function getUrlExtension(url: string | unknown) {
22 | if (typeof url === 'string') {
23 | return url.split(/[#?]/)[0].split('.').pop()?.trim();
24 | }
25 | }
26 |
27 | // Note: doesn't get updated when the callback function changes
28 | export function usePolling(
29 | callback: (abortSignal: AbortSignal) => any,
30 | interval: number | null = DEFAULT_POLLING_INTERVAL
31 | ) {
32 | const abortControllerRef = useRef(new AbortController());
33 |
34 | useEffect(() => {
35 | abortControllerRef.current = new AbortController();
36 | callback(abortControllerRef.current.signal);
37 |
38 | return () => {
39 | // Effects run twice in dev mode so this will run once.
40 | abortControllerRef.current.abort();
41 | };
42 | }, []);
43 |
44 | const intervalFn = useCallback(() => {
45 | return callback(abortControllerRef.current.signal);
46 | }, []);
47 |
48 | useInterval(intervalFn, interval);
49 | }
50 |
51 | export function useInterval(callback: () => any, delay: number | null) {
52 | const savedCallback = useRef<() => any>(null);
53 |
54 | // Remember the latest callback.
55 | useEffect(() => {
56 | savedCallback.current = callback;
57 | });
58 |
59 | // Set up the interval.
60 | useEffect(() => {
61 | const tick = async () => {
62 | await savedCallback.current?.();
63 | };
64 |
65 | if (delay != null) {
66 | let id = setInterval(tick, delay);
67 | return () => clearInterval(id);
68 | }
69 | }, [delay]);
70 | }
71 |
72 | export function isReactComponent(
73 | component: unknown
74 | ): component is React.ComponentType {
75 | return (
76 | isClassComponent(component) ||
77 | typeof component === 'function' ||
78 | isExoticComponent(component)
79 | );
80 | }
81 |
82 | function isClassComponent(component: any) {
83 | return (
84 | typeof component === 'function' &&
85 | (() => {
86 | const proto = Object.getPrototypeOf(component);
87 | return proto.prototype && proto.prototype.isReactComponent;
88 | })()
89 | );
90 | }
91 |
92 | function isExoticComponent(component: any) {
93 | return (
94 | typeof component === 'object' &&
95 | typeof component.$$typeof === 'symbol' &&
96 | ['react.memo', 'react.forward_ref'].includes(component.$$typeof.description)
97 | );
98 | }
99 |
100 | export function svgBlurImage(blurDataURL: string) {
101 | const svg = /*html*/` `;
102 | return svg.replace(/#/g, '%23');
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/video-loader.ts:
--------------------------------------------------------------------------------
1 | import { config } from './utils.js';
2 | import type { Asset } from '../assets.js';
3 | import type { VideoLoaderProps, VideoLoaderPropsWithConfig, VideoLoaderWithConfig } from './types';
4 |
5 | export async function defaultLoader({ config, src, width, height }: VideoLoaderPropsWithConfig) {
6 | let requestUrl = `${config.path}?url=${encodeURIComponent(`${src}`)}`;
7 | if (width) requestUrl += `&w=${width}`;
8 | if (height) requestUrl += `&h=${height}`;
9 | return `${requestUrl}`;
10 | }
11 |
12 | export function createVideoRequest(
13 | loader: VideoLoaderWithConfig,
14 | props: VideoLoaderProps,
15 | callback: (json: Asset) => void
16 | ) {
17 | return async (abortSignal: AbortSignal) => {
18 | if (typeof props.src !== 'string') return;
19 |
20 | try {
21 | const requestUrl = await loader({
22 | ...props,
23 | config,
24 | });
25 | const res = await fetch(requestUrl, { signal: abortSignal });
26 | const json = await res.json();
27 | if (res.ok) {
28 | callback(json);
29 | } else {
30 | let message = `[next-video] The request to ${res.url} failed. `;
31 | message += `Did you configure the \`${config.path}\` route to handle video API requests?\n`;
32 | throw new Error(message);
33 | }
34 | } catch (err) {
35 | if (!abortSignal.aborted) {
36 | console.error(err)
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/video.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { forwardRef, useState } from 'react';
4 | import DefaultPlayer from './players/default-player.js';
5 | import { Alert } from './alert.js';
6 | import { createVideoRequest, defaultLoader } from './video-loader.js';
7 | import * as transformers from '../providers/transformers.js';
8 | import {
9 | config,
10 | camelCase,
11 | toSymlinkPath,
12 | usePolling,
13 | isReactComponent,
14 | getUrlExtension,
15 | } from './utils.js';
16 |
17 | import type { Asset } from '../assets.js';
18 | import type { VideoLoaderProps, VideoProps, VideoPropsInternal } from './types.js';
19 | export type * from './types.js';
20 |
21 | const NextVideo = forwardRef((props, forwardedRef) => {
22 | // Keep in component so we can emulate the DEV_MODE.
23 | const DEV_MODE = process.env.NODE_ENV === 'development';
24 |
25 | let {
26 | as: VideoPlayer = DefaultPlayer,
27 | loader = defaultLoader,
28 | transform = defaultTransformer,
29 | className,
30 | style,
31 | src,
32 | width,
33 | height,
34 | } = props;
35 |
36 | let [asset, setAsset] = useState(typeof src === 'object' ? src : undefined);
37 | const [playing, setPlaying] = useState(false);
38 |
39 | // Required to make Next.js fast refresh when the local JSON file changes.
40 | // https://nextjs.org/docs/architecture/fast-refresh#fast-refresh-and-hooks
41 | if (typeof src === 'object') {
42 | asset = src;
43 | src = undefined;
44 | }
45 |
46 | // If the source is a string, poll the server for the JSON file.
47 | const loaderProps: VideoLoaderProps = { src, width, height };
48 | const request = createVideoRequest(loader, loaderProps, (json) => setAsset(json));
49 |
50 | const status = asset?.status;
51 |
52 | // Polling is only needed for unprocessed video sources (not HLS or DASH).
53 | const fileExtension = getUrlExtension(src);
54 | const needsPolling =
55 | DEV_MODE &&
56 | typeof src === 'string' &&
57 | status != 'ready' &&
58 | !['m3u8', 'mpd'].includes(fileExtension ?? '');
59 |
60 | usePolling(request, needsPolling ? 1000 : null);
61 |
62 | const videoProps = getVideoProps({ ...props, transform, src } as VideoPropsInternal, { asset });
63 |
64 | if (!isReactComponent(VideoPlayer)) {
65 | console.warn('The `as` property is not a valid component:', VideoPlayer);
66 | }
67 |
68 | return (
69 |
70 |
92 |
93 |
setPlaying(true)}
99 | onPause={() => setPlaying(false)}
100 | {...videoProps}
101 | >
102 |
103 | {DEV_MODE && (
104 |
105 | )}
106 |
107 | );
108 | });
109 |
110 | export function getVideoProps(allProps: VideoPropsInternal, state: { asset?: Asset }) {
111 | const { asset } = state;
112 | // Remove props that are not needed for VideoPlayer.
113 | const {
114 | controls = true,
115 | as,
116 | className,
117 | style,
118 | src,
119 | poster,
120 | blurDataURL,
121 | loader,
122 | transform,
123 | ...rest
124 | } = allProps;
125 |
126 | const props = {
127 | src: src as string | undefined,
128 | poster: poster as string | undefined,
129 | controls,
130 | blurDataURL,
131 | ...rest,
132 | };
133 |
134 | // Handle StaticImageData which are image imports that resolve to an object.
135 | if (typeof poster === 'object') {
136 | props.poster = poster.src;
137 | props.blurDataURL ??= poster.blurDataURL;
138 | }
139 |
140 | if (asset) {
141 | if (asset.status === 'ready') {
142 | props.blurDataURL ??= asset.blurDataURL;
143 |
144 | const transformedAsset = transform(asset, props);
145 | if (transformedAsset) {
146 | // src can't be overridden by the user.
147 | props.src = transformedAsset.sources?.[0]?.src;
148 | props.poster ??= transformedAsset.poster;
149 | props.thumbnailTime ??= transformedAsset.thumbnailTime as number;
150 | }
151 | } else {
152 | props.src = toSymlinkPath(asset.originalFilePath);
153 | }
154 | }
155 |
156 | return props;
157 | }
158 |
159 | function defaultTransformer(asset: Asset, props: Record) {
160 | const provider = asset.provider ?? config.provider;
161 | for (let [key, transformer] of Object.entries(transformers)) {
162 | if (key === camelCase(provider)) {
163 | return transformer.transform(asset, props);
164 | }
165 | }
166 | }
167 |
168 | export default NextVideo;
169 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { mkdir, readFile, writeFile } from 'node:fs/promises';
3 | import { cwd } from 'node:process';
4 | import type { NextConfig } from 'next';
5 | import { Asset } from './assets';
6 |
7 | /**
8 | * Video configurations
9 | */
10 | export type VideoConfigComplete = {
11 | /** The folder in your project where you will put all video source files. */
12 | folder: string;
13 |
14 | /** The route of the video API request for string video source URLs. */
15 | path: string;
16 |
17 | /* The default provider that will deliver your video. */
18 | provider: keyof ProviderConfig;
19 |
20 | /* Config by provider. */
21 | providerConfig: ProviderConfig;
22 |
23 | /* An function to retrieve asset data, by default use the filesystem */
24 | loadAsset: (assetPath: string) => Promise;
25 |
26 | /* An function to save asset data, by default use the filesystem */
27 | saveAsset: (assetPath: string, asset: Asset) => Promise;
28 |
29 | /* An function to update asset data, by default use the filesystem */
30 | updateAsset: (assetPath: string, asset: Asset) => Promise;
31 |
32 | /* An optional function to generate the local asset path for remote sources. */
33 | remoteSourceAssetPath?: (url: string) => string;
34 | };
35 |
36 | export type ProviderConfig = {
37 | mux?: {
38 | generateAssetKey: undefined;
39 | videoQuality?: 'basic' | 'plus' | 'premium';
40 | };
41 |
42 | 'vercel-blob'?: {
43 | /* An optional function to generate the bucket asset key. */
44 | generateAssetKey?: (filePathOrURL: string, folder: string) => string;
45 | };
46 |
47 | backblaze?: {
48 | endpoint: string;
49 | bucket?: string;
50 | accessKeyId?: string;
51 | secretAccessKey?: string;
52 | /* An optional function to generate the bucket asset key. */
53 | generateAssetKey?: (filePathOrURL: string, folder: string) => string;
54 | };
55 |
56 | 'amazon-s3'?: {
57 | endpoint: string;
58 | bucket?: string;
59 | accessKeyId?: string;
60 | secretAccessKey?: string;
61 | /* An optional function to generate the bucket asset key. */
62 | generateAssetKey?: (filePathOrURL: string, folder: string) => string;
63 | };
64 |
65 | 'cloudflare-r2'?: {
66 | endpoint: string;
67 | bucket?: string;
68 | bucketUrlPublic?: string;
69 | accessKeyId?: string;
70 | secretAccessKey?: string;
71 | apiToken?: string;
72 | /* An optional function to generate the bucket asset key. */
73 | generateAssetKey?: (filePathOrURL: string, folder: string) => string;
74 | };
75 | };
76 |
77 | export type VideoConfig = Partial;
78 |
79 | export const videoConfigDefault: VideoConfigComplete = {
80 | folder: 'videos',
81 | path: '/api/video',
82 | provider: 'mux',
83 | providerConfig: {},
84 | loadAsset: async function (assetPath: string): Promise {
85 | const file = await readFile(assetPath);
86 | const asset = JSON.parse(file.toString());
87 | return asset;
88 | },
89 | saveAsset: async function (assetPath: string, asset: Asset): Promise {
90 | try {
91 | await mkdir(path.dirname(assetPath), { recursive: true });
92 | await writeFile(assetPath, JSON.stringify(asset), {
93 | flag: 'wx',
94 | });
95 | } catch (err: any) {
96 | if (err.code === 'EEXIST') {
97 | // The file already exists, and that's ok in this case. Ignore the error.
98 | return;
99 | }
100 | throw err;
101 | }
102 | },
103 | updateAsset: async function (assetPath: string, asset: Asset): Promise {
104 | await writeFile(assetPath, JSON.stringify(asset));
105 | }
106 | };
107 |
108 | declare global {
109 | var __nextVideo: {
110 | configComplete: VideoConfigComplete;
111 | configIsDefined: boolean;
112 | }
113 | }
114 |
115 | // globalThis is used here because in Next 15 when a next.config.ts is transpiled and imported
116 | // I believe this module is imported again in a different context and the module state is lost.
117 |
118 | globalThis.__nextVideo = {
119 | configComplete: videoConfigDefault,
120 | configIsDefined: false
121 | };
122 |
123 | export function setVideoConfig(videoConfig?: VideoConfig): VideoConfigComplete {
124 | globalThis.__nextVideo.configIsDefined = true;
125 | globalThis.__nextVideo.configComplete = { ...videoConfigDefault, ...videoConfig };
126 | return globalThis.__nextVideo.configComplete;
127 | }
128 |
129 | /**
130 | * The video config is set in `next.config.js` and passed to the `withNextVideo` function.
131 | * The video config is then stored via the `setVideoConfig` function.
132 | */
133 | export async function getVideoConfig(): Promise {
134 | // This condition is only true for the next-video CLI commands.
135 | if (!globalThis.__nextVideo.configIsDefined) {
136 | const nextConfigModule = (await import(/* webpackIgnore: true */ 'next/dist/server/config.js')).default;
137 | const loadNextConfig = ((nextConfigModule as any).default ?? nextConfigModule) as typeof nextConfigModule;
138 | await loadNextConfig('phase-development-server', cwd());
139 | }
140 | return globalThis.__nextVideo.configComplete;
141 | }
142 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const PACKAGE_NAME = 'next-video';
2 |
--------------------------------------------------------------------------------
/src/handlers/api-request.ts:
--------------------------------------------------------------------------------
1 | /* c8 ignore start */
2 | import * as providers from '../providers/providers.js';
3 | import { camelCase } from '../utils/utils.js';
4 | import type { Asset } from '../assets.js';
5 | import type { HandlerConfig } from '../video-handler.js';
6 |
7 | export async function uploadRequestedFile(asset: Asset, config: HandlerConfig) {
8 | for (let [key, provider] of Object.entries(providers)) {
9 | if (key === camelCase(config.provider)) {
10 | return provider.uploadRequestedFile(asset);
11 | }
12 | }
13 | }
14 | /* c8 ignore stop */
15 |
--------------------------------------------------------------------------------
/src/handlers/local-upload.ts:
--------------------------------------------------------------------------------
1 | import * as providers from '../providers/providers.js';
2 | import { camelCase } from '../utils/utils.js';
3 | import type { Asset } from '../assets.js';
4 | import type { HandlerConfig } from '../video-handler.js';
5 |
6 | export async function uploadLocalFile(asset: Asset, config: HandlerConfig) {
7 | for (let [key, provider] of Object.entries(providers)) {
8 | if (key === camelCase(config.provider)) {
9 | return provider.uploadLocalFile(asset);
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/process.ts:
--------------------------------------------------------------------------------
1 | import { videoHandler, callHandler } from './video-handler.js';
2 | import { uploadLocalFile } from './handlers/local-upload.js';
3 | import { uploadRequestedFile } from './handlers/api-request.js';
4 | import log from './utils/logger.js';
5 | import { NextVideo } from './setup-next-video.js';
6 | import { withNextVideo } from './with-next-video.js';
7 |
8 | try {
9 | // Don't love this little race condition... we gotta figure that one out.
10 | // Basically we need to make sure all the handlers are registered before we start watching for files.
11 | videoHandler('local.video.added', uploadLocalFile);
12 | videoHandler('request.video.added', uploadRequestedFile);
13 |
14 | } catch (err) {
15 | // We'd much prefer to log an error here than crash since it can put
16 | // the main Next process in a weird state.
17 | log.error('An exception occurred within next-video. You may need to restart your dev server.');
18 | console.error(err);
19 | }
20 |
21 | export { videoHandler, callHandler, NextVideo, withNextVideo };
22 |
--------------------------------------------------------------------------------
/src/providers/amazon-s3/provider.ts:
--------------------------------------------------------------------------------
1 | import { ReadStream, createReadStream } from 'node:fs';
2 | import { Readable } from 'node:stream';
3 | import fs from 'node:fs/promises';
4 | import { env } from 'node:process';
5 | import { fetch as uFetch } from 'undici';
6 | import chalk from 'chalk';
7 | import cuid2 from '@paralleldrive/cuid2';
8 | import { S3Client } from '@aws-sdk/client-s3';
9 |
10 | import { updateAsset, Asset } from '../../assets.js';
11 | import { getVideoConfig } from '../../config.js';
12 | import { findBucket, createBucket, putBucketCors, putObject, putBucketAcl } from '../../utils/s3.js';
13 | import { createAssetKey } from '../../utils/provider.js';
14 | import { isRemote } from '../../utils/utils.js';
15 | import log from '../../utils/logger.js';
16 |
17 | export type AmazonS3Metadata = {
18 | bucket?: string;
19 | endpoint?: string;
20 | key?: string;
21 | }
22 |
23 | // Why 11?
24 | // - Reasonable id length visually in the src URL
25 | // - Familiarity with the length of YouTube IDs
26 | // - It would take more than 300 million buckets to have a 50% chance of a collision.
27 | // - "These go to eleven" https://www.youtube.com/watch?v=F7IZZXQ89Oc
28 | const createId = cuid2.init({ length: 11 });
29 |
30 | let s3: S3Client;
31 | let bucketName: string;
32 | let endpoint: string;
33 |
34 | async function initS3() {
35 | const { providerConfig } = await getVideoConfig();
36 | const amazonS3Config = providerConfig['amazon-s3'];
37 | bucketName = amazonS3Config?.bucket ?? '';
38 | endpoint = amazonS3Config?.endpoint ?? '';
39 |
40 | const regionMatch = endpoint.match(/\.([a-z0-9-]+)\.amazonaws\.com$/);
41 | const region = regionMatch ? regionMatch[1] : '';
42 |
43 | s3 ??= new S3Client({
44 | endpoint,
45 | region,
46 | credentials: {
47 | accessKeyId: amazonS3Config?.accessKeyId ?? env.AWS_ACCESS_KEY_ID ?? '',
48 | secretAccessKey: amazonS3Config?.secretAccessKey ?? env.AWS_SECRET_ACCESS_KEY ?? '',
49 | }
50 | });
51 |
52 | if (!bucketName) {
53 | try {
54 | const bucket = await findBucket(s3, bucket => bucket.Name?.startsWith('next-videos-'));
55 |
56 | if (bucket) {
57 | bucketName = bucket.Name!;
58 | log.info(log.label('Using existing Amazon S3 bucket:'), bucketName);
59 | }
60 | } catch (err) {
61 | log.error('Error listing Amazon S3 buckets');
62 | console.error(err);
63 | }
64 | }
65 |
66 | if (!bucketName) {
67 | bucketName = `next-videos-${createId()}`;
68 | log.info(log.label('Creating Amazon S3 bucket:'), bucketName);
69 |
70 | try {
71 | await createBucket(s3, bucketName, {
72 | // https://aws.amazon.com/blogs/aws/heads-up-amazon-s3-security-changes-are-coming-in-april-of-2023/
73 |
74 | // Can't set ACL here since the security changes, but we can set it after the bucket is created.
75 | // S3ServiceException [InvalidBucketAclWithBlockPublicAccessError]: Bucket cannot have public ACLs set with BlockPublicAccess enabled
76 | // ACL: 'public-read',
77 |
78 | // Since the security changes the default ObjectOwnership is BucketOwnerEnforced which doesn't allow ACLs. Change it here.
79 | // InvalidBucketAclWithObjectOwnership: Bucket cannot have ACLs set with ObjectOwnership's BucketOwnerEnforced setting
80 | ObjectOwnership: 'ObjectWriter'
81 | });
82 | await putBucketAcl(s3, bucketName);
83 | await putBucketCors(s3, bucketName);
84 | } catch (err) {
85 | log.error('Error creating Amazon S3 bucket');
86 | console.error(err);
87 | }
88 | }
89 | }
90 |
91 | export async function uploadLocalFile(asset: Asset) {
92 | const filePath = asset.originalFilePath;
93 |
94 | if (!filePath) {
95 | log.error('No filePath provided for asset.');
96 | console.error(asset);
97 | return;
98 | }
99 |
100 | // Handle imported remote videos.
101 | if (isRemote(filePath)) {
102 | return uploadRequestedFile(asset);
103 | }
104 |
105 | if (asset.status === 'ready') {
106 | return;
107 | } else if (asset.status === 'uploading') {
108 | // Right now this re-starts the upload from the beginning.
109 | // We should probably do something smarter here.
110 | log.info(log.label('Resuming upload:'), filePath);
111 | }
112 |
113 | await updateAsset(filePath, {
114 | status: 'uploading'
115 | });
116 |
117 | await initS3();
118 |
119 | const fileStats = await fs.stat(filePath);
120 | const stream = createReadStream(filePath);
121 |
122 | return putAsset(filePath, fileStats.size, stream);
123 | }
124 |
125 | export async function uploadRequestedFile(asset: Asset) {
126 | const filePath = asset.originalFilePath;
127 |
128 | if (!filePath) {
129 | log.error('No URL provided for asset.');
130 | console.error(asset);
131 | return;
132 | }
133 |
134 | if (asset.status === 'ready') {
135 | return;
136 | }
137 |
138 | await updateAsset(filePath, {
139 | status: 'uploading'
140 | });
141 |
142 | await initS3();
143 |
144 | const response = await uFetch(filePath);
145 | const size = Number(response.headers.get('content-length'));
146 | const stream = response.body;
147 |
148 | if (!stream) {
149 | log.error('Error fetching the requested file:', filePath);
150 | return;
151 | }
152 |
153 | return putAsset(filePath, size, Readable.fromWeb(stream));
154 | }
155 |
156 | async function putAsset(filePath: string, size: number, stream: ReadStream | Readable) {
157 | log.info(log.label('Uploading file:'), `${filePath} (${size} bytes)`);
158 |
159 | let key;
160 | try {
161 | key = await createAssetKey(filePath, 'amazon-s3');
162 |
163 | await putObject(s3, {
164 | ACL: 'public-read',
165 | Bucket: bucketName,
166 | Key: key,
167 | Body: stream,
168 | ContentLength: size,
169 | });
170 |
171 | if (stream instanceof ReadStream) {
172 | stream.close();
173 | }
174 | } catch (e) {
175 | log.error('Error uploading to Amazon S3');
176 | console.error(e);
177 | return;
178 | }
179 |
180 | log.success(log.label('File uploaded:'), `${filePath} (${size} bytes)`);
181 |
182 | const updatedAsset = await updateAsset(filePath, {
183 | status: 'ready',
184 | providerMetadata: {
185 | 'amazon-s3': {
186 | endpoint,
187 | bucket: bucketName,
188 | key,
189 | } as AmazonS3Metadata
190 | },
191 | });
192 |
193 | const url = updatedAsset.sources?.[0].src;
194 | log.space(chalk.gray('>'), log.label('URL:'), url);
195 |
196 | return updatedAsset;
197 | }
198 |
--------------------------------------------------------------------------------
/src/providers/amazon-s3/transformer.ts:
--------------------------------------------------------------------------------
1 | import type { Asset, AssetSource } from '../../assets.js';
2 |
3 | export function transform(asset: Asset) {
4 | const providerMetadata = asset.providerMetadata?.['amazon-s3'];
5 | if (!providerMetadata) return asset;
6 |
7 | const src = new URL(providerMetadata.endpoint);
8 | src.hostname = `${providerMetadata.bucket}.${src.hostname}`;
9 | src.pathname = providerMetadata.key;
10 |
11 | const source: AssetSource = { src: `${src}` };
12 |
13 | return {
14 | ...asset,
15 | sources: [source],
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/providers/backblaze/provider.ts:
--------------------------------------------------------------------------------
1 | import { ReadStream, createReadStream } from 'node:fs';
2 | import { Readable } from 'node:stream';
3 | import fs from 'node:fs/promises';
4 | import { env } from 'node:process';
5 | import { fetch as uFetch } from 'undici';
6 | import chalk from 'chalk';
7 | import cuid2 from '@paralleldrive/cuid2';
8 | import { S3Client } from '@aws-sdk/client-s3';
9 |
10 | import { updateAsset, Asset } from '../../assets.js';
11 | import { getVideoConfig } from '../../config.js';
12 | import { findBucket, createBucket, putBucketCors, putObject } from '../../utils/s3.js';
13 | import { createAssetKey } from '../../utils/provider.js';
14 | import { isRemote } from '../../utils/utils.js';
15 | import log from '../../utils/logger.js';
16 |
17 | export type BackblazeMetadata = {
18 | bucket?: string;
19 | endpoint?: string;
20 | key?: string;
21 | }
22 |
23 | // Why 11?
24 | // - Reasonable id length visually in the src URL
25 | // - Familiarity with the length of YouTube IDs
26 | // - It would take more than 300 million buckets to have a 50% chance of a collision.
27 | // - "These go to eleven" https://www.youtube.com/watch?v=F7IZZXQ89Oc
28 | const createId = cuid2.init({ length: 11 });
29 |
30 | let s3: S3Client;
31 | let bucketName: string;
32 | let endpoint: string;
33 |
34 | async function initS3() {
35 | const { providerConfig } = await getVideoConfig();
36 | const backblazeConfig = providerConfig.backblaze;
37 | bucketName = backblazeConfig?.bucket ?? '';
38 | endpoint = backblazeConfig?.endpoint ?? '';
39 |
40 | const regionMatch = endpoint.match(/\.([a-z0-9-]+)\.backblazeb2\.com$/);
41 | const region = regionMatch ? regionMatch[1] : '';
42 |
43 | s3 ??= new S3Client({
44 | endpoint,
45 | region,
46 | credentials: {
47 | accessKeyId: backblazeConfig?.accessKeyId ?? env.BACKBLAZE_ACCESS_KEY_ID ?? '',
48 | secretAccessKey: backblazeConfig?.secretAccessKey ?? env.BACKBLAZE_SECRET_ACCESS_KEY ?? '',
49 | }
50 | });
51 |
52 | if (!bucketName) {
53 | try {
54 | const bucket = await findBucket(s3, bucket => bucket.Name?.startsWith('next-videos-'));
55 |
56 | if (bucket) {
57 | bucketName = bucket.Name!;
58 | log.info(log.label('Using existing Backblaze bucket:'), bucketName);
59 | }
60 | } catch (err) {
61 | log.error('Error listing Backblaze buckets');
62 | console.error(err);
63 | }
64 | }
65 |
66 | if (!bucketName) {
67 | bucketName = `next-videos-${createId()}`;
68 | log.info(log.label('Creating Backblaze bucket:'), bucketName);
69 |
70 | try {
71 | await createBucket(s3, bucketName, {
72 | ACL: 'public-read',
73 | });
74 | await putBucketCors(s3, bucketName);
75 | } catch (err) {
76 | log.error('Error creating Backblaze bucket');
77 | console.error(err);
78 | }
79 | }
80 | }
81 |
82 | export async function uploadLocalFile(asset: Asset) {
83 | const filePath = asset.originalFilePath;
84 |
85 | if (!filePath) {
86 | log.error('No filePath provided for asset.');
87 | console.error(asset);
88 | return;
89 | }
90 |
91 | // Handle imported remote videos.
92 | if (isRemote(filePath)) {
93 | return uploadRequestedFile(asset);
94 | }
95 |
96 | if (asset.status === 'ready') {
97 | return;
98 | } else if (asset.status === 'uploading') {
99 | // Right now this re-starts the upload from the beginning.
100 | // We should probably do something smarter here.
101 | log.info(log.label('Resuming upload:'), filePath);
102 | }
103 |
104 | await updateAsset(filePath, {
105 | status: 'uploading'
106 | });
107 |
108 | await initS3();
109 |
110 | const fileStats = await fs.stat(filePath);
111 | const stream = createReadStream(filePath);
112 |
113 | return putAsset(filePath, fileStats.size, stream);
114 | }
115 |
116 | export async function uploadRequestedFile(asset: Asset) {
117 | const filePath = asset.originalFilePath;
118 |
119 | if (!filePath) {
120 | log.error('No URL provided for asset.');
121 | console.error(asset);
122 | return;
123 | }
124 |
125 | if (asset.status === 'ready') {
126 | return;
127 | }
128 |
129 | await updateAsset(filePath, {
130 | status: 'uploading'
131 | });
132 |
133 | await initS3();
134 |
135 | const response = await uFetch(filePath);
136 | const size = Number(response.headers.get('content-length'));
137 | const stream = response.body;
138 |
139 | if (!stream) {
140 | log.error('Error fetching the requested file:', filePath);
141 | return;
142 | }
143 |
144 | return putAsset(filePath, size, Readable.fromWeb(stream));
145 | }
146 |
147 | async function putAsset(filePath: string, size: number, stream: ReadStream | Readable) {
148 | log.info(log.label('Uploading file:'), `${filePath} (${size} bytes)`);
149 |
150 | let key;
151 | try {
152 | key = await createAssetKey(filePath, 'backblaze');
153 |
154 | await putObject(s3, {
155 | Bucket: bucketName,
156 | Key: key,
157 | Body: stream,
158 | ContentLength: size,
159 | });
160 |
161 | if (stream instanceof ReadStream) {
162 | stream.close();
163 | }
164 | } catch (e) {
165 | log.error('Error uploading to Backblaze B2');
166 | console.error(e);
167 | return;
168 | }
169 |
170 | log.success(log.label('File uploaded:'), `${filePath} (${size} bytes)`);
171 |
172 | const updatedAsset = await updateAsset(filePath, {
173 | status: 'ready',
174 | providerMetadata: {
175 | backblaze: {
176 | endpoint,
177 | bucket: bucketName,
178 | key,
179 | } as BackblazeMetadata
180 | },
181 | });
182 |
183 | const url = updatedAsset.sources?.[0].src;
184 | log.space(chalk.gray('>'), log.label('URL:'), url);
185 |
186 | return updatedAsset;
187 | }
188 |
--------------------------------------------------------------------------------
/src/providers/backblaze/transformer.ts:
--------------------------------------------------------------------------------
1 | import type { Asset, AssetSource } from '../../assets.js';
2 |
3 | export function transform(asset: Asset) {
4 | const providerMetadata = asset.providerMetadata?.backblaze;
5 | if (!providerMetadata) return asset;
6 |
7 | const src = new URL(providerMetadata.endpoint);
8 | src.hostname = `${providerMetadata.bucket}.${src.hostname}`;
9 | src.pathname = providerMetadata.key;
10 |
11 | const source: AssetSource = { src: `${src}` };
12 |
13 | return {
14 | ...asset,
15 | sources: [source],
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/providers/cloudflare-r2/provider.ts:
--------------------------------------------------------------------------------
1 | import { ReadStream, createReadStream } from 'node:fs';
2 | import { Readable } from 'node:stream';
3 | import fs from 'node:fs/promises';
4 | import { env } from 'node:process';
5 | import { fetch as uFetch } from 'undici';
6 | import chalk from 'chalk';
7 | import cuid2 from '@paralleldrive/cuid2';
8 | import { S3Client } from '@aws-sdk/client-s3';
9 |
10 | import { updateAsset, Asset } from '../../assets.js';
11 | import { getVideoConfig } from '../../config.js';
12 | import { findBucket, createBucket, putBucketCors, putObject } from '../../utils/s3.js';
13 | import { createAssetKey } from '../../utils/provider.js';
14 | import { isRemote } from '../../utils/utils.js';
15 | import log from '../../utils/logger.js';
16 | import { publicAccessR2Bucket } from '../../utils/r2.js';
17 |
18 | export type CloudflareR2Metadata = {
19 | bucket?: string;
20 | endpoint?: string;
21 | key?: string;
22 | };
23 |
24 | // Why 11?
25 | // - Reasonable id length visually in the src URL
26 | // - Familiarity with the length of YouTube IDs
27 | // - It would take more than 300 million buckets to have a 50% chance of a collision.
28 | // - "These go to eleven" https://www.youtube.com/watch?v=F7IZZXQ89Oc
29 | const createId = cuid2.init({ length: 11 });
30 |
31 | let s3: S3Client;
32 | let bucketName: string;
33 | let bucketUrlPublic: string;
34 | let accountId: string;
35 | let endpoint: string;
36 |
37 | async function initR2() {
38 | const { providerConfig } = await getVideoConfig();
39 | const CloudflareR2Config = providerConfig['cloudflare-r2'];
40 |
41 | bucketName = CloudflareR2Config?.bucket ?? '';
42 | bucketUrlPublic = CloudflareR2Config?.bucketUrlPublic ?? '';
43 | endpoint = CloudflareR2Config?.endpoint ?? '';
44 | accountId = endpoint.split('.')[0].replace(/^https?:\/\//, '');
45 |
46 | s3 ??= new S3Client({
47 | endpoint,
48 | // region does not have any impact on Cloudflare R2
49 | region: 'auto',
50 | credentials: {
51 | accessKeyId: CloudflareR2Config?.accessKeyId ?? env.R2_ACCESS_KEY_ID ?? '',
52 | secretAccessKey: CloudflareR2Config?.secretAccessKey ?? env.R2_SECRET_ACCESS_KEY ?? '',
53 | },
54 | });
55 |
56 | if (!bucketName) {
57 | try {
58 | const bucket = await findBucket(s3, (bucket) => bucket.Name?.startsWith('next-videos-'));
59 |
60 | if (bucket) {
61 | bucketName = bucket.Name!;
62 | log.info(log.label('Using existing Cloudflare R2 bucket:'), bucketName);
63 | }
64 | } catch (err) {
65 | log.error('Error listing Cloudflare R2 buckets');
66 | console.error(err);
67 | }
68 | }
69 |
70 | if (!bucketName) {
71 | bucketName = `next-videos-${createId()}`;
72 | log.info(log.label('Creating Cloudflare R2 bucket:'), bucketName);
73 |
74 | try {
75 | await createBucket(s3, bucketName, {});
76 | await putBucketCors(s3, bucketName);
77 | } catch (err) {
78 | log.error('Error creating Cloudflare R2 bucket');
79 | console.error(err);
80 | }
81 | }
82 |
83 | if (!bucketUrlPublic && bucketName) {
84 | const cloudflareApiToken = CloudflareR2Config?.apiToken ?? env.R2_CF_API_TOKEN ?? '';
85 | let bucketPublicId: string;
86 | if (cloudflareApiToken) {
87 | try {
88 | bucketPublicId = (await publicAccessR2Bucket(accountId, bucketName, cloudflareApiToken)) ?? '';
89 | bucketUrlPublic = `https://${bucketPublicId}`;
90 | } catch (e) {
91 | log.error(`Error setting Public access for Cloudflare R2 bucket: ${bucketName}`);
92 | console.error(e);
93 | return;
94 | }
95 | }
96 | }
97 | }
98 |
99 | export async function uploadLocalFile(asset: Asset) {
100 | const filePath = asset.originalFilePath;
101 |
102 | if (!filePath) {
103 | log.error('No filePath provided for asset.');
104 | console.error(asset);
105 | return;
106 | }
107 |
108 | // Handle imported remote videos.
109 | if (isRemote(filePath)) {
110 | return uploadRequestedFile(asset);
111 | }
112 |
113 | if (asset.status === 'ready') {
114 | return;
115 | } else if (asset.status === 'uploading') {
116 | // Right now this re-starts the upload from the beginning.
117 | // We should probably do something smarter here.
118 | log.info(log.label('Resuming upload:'), filePath);
119 | }
120 |
121 | await updateAsset(filePath, {
122 | status: 'uploading',
123 | });
124 |
125 | await initR2();
126 |
127 | if (!bucketUrlPublic) {
128 | log.error(
129 | `Public access configuration missing:
130 | Neither the Cloudflare API Key nor the bucketUrlPublic URL is specified for the bucket "${bucketName}".
131 |
132 | To enable public access, you must ensure one of the following:
133 | 1. **Configure the Bucket for Public Access:**
134 | - Make sure the bucket "${bucketName}" is configured for public access
135 | and specify the public URL in the provider configuration under the key 'bucketUrlPublic'.
136 | - For detailed instructions, refer to the Cloudflare documentation:
137 | https://developers.cloudflare.com/r2/buckets/public-buckets/
138 |
139 | 2. **Provide a Cloudflare API Key:**
140 | - You can specify a Cloudflare API Key with R2 Admin read & write permissions using the environment variable: R2_CF_API_TOKEN.
141 | - This API Key will allow us to enable public access for the bucket and retrieve the public URL using the Cloudflare API.
142 | - To create an API Token, visit:
143 | https://dash.cloudflare.com/?to=/:account/r2/api-tokens`
144 | );
145 | return;
146 | }
147 |
148 | const fileStats = await fs.stat(filePath);
149 | const stream = createReadStream(filePath);
150 |
151 | return putAsset(filePath, fileStats.size, stream);
152 | }
153 |
154 | export async function uploadRequestedFile(asset: Asset) {
155 | const filePath = asset.originalFilePath;
156 |
157 | if (!filePath) {
158 | log.error('No URL provided for asset.');
159 | console.error(asset);
160 | return;
161 | }
162 |
163 | if (asset.status === 'ready') {
164 | return;
165 | }
166 |
167 | await updateAsset(filePath, {
168 | status: 'uploading',
169 | });
170 |
171 | await initR2();
172 |
173 | const response = await uFetch(filePath);
174 | const size = Number(response.headers.get('content-length'));
175 | const stream = response.body;
176 |
177 | if (!stream) {
178 | log.error('Error fetching the requested file:', filePath);
179 | return;
180 | }
181 |
182 | return putAsset(filePath, size, Readable.fromWeb(stream));
183 | }
184 |
185 | async function putAsset(filePath: string, size: number, stream: ReadStream | Readable) {
186 | log.info(log.label('Uploading file:'), `${filePath} (${size} bytes)`);
187 |
188 | let key;
189 | try {
190 | key = await createAssetKey(filePath, 'cloudflare-r2');
191 |
192 | await putObject(s3, {
193 | ACL: 'public-read',
194 | Bucket: bucketName,
195 | Key: key,
196 | Body: stream,
197 | ContentLength: size,
198 | });
199 |
200 | if (stream instanceof ReadStream) {
201 | stream.close();
202 | }
203 | } catch (e) {
204 | log.error('Error uploading to Cloudflare R2');
205 | console.error(e);
206 | return;
207 | }
208 |
209 | log.success(log.label('File uploaded:'), `${filePath} (${size} bytes)`);
210 |
211 | const updatedAsset = await updateAsset(filePath, {
212 | status: 'ready',
213 | providerMetadata: {
214 | 'cloudflare-r2': {
215 | endpoint,
216 | bucketUrlPublic,
217 | bucket: bucketName,
218 | key,
219 | } as CloudflareR2Metadata,
220 | },
221 | });
222 |
223 | const url = updatedAsset.sources?.[0].src;
224 | log.space(chalk.gray('>'), log.label('URL:'), url);
225 |
226 | return updatedAsset;
227 | }
228 |
--------------------------------------------------------------------------------
/src/providers/cloudflare-r2/transformer.ts:
--------------------------------------------------------------------------------
1 | import type { Asset, AssetSource } from '../../assets.js';
2 |
3 | export function transform(asset: Asset) {
4 | const providerMetadata = asset.providerMetadata?.['cloudflare-r2'];
5 | if (!providerMetadata) return asset;
6 |
7 | const src = new URL(providerMetadata.bucketUrlPublic);
8 | src.pathname = providerMetadata.key;
9 |
10 | const source: AssetSource = { src: `${src}` };
11 |
12 | return {
13 | ...asset,
14 | sources: [source],
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/src/providers/mux/provider.ts:
--------------------------------------------------------------------------------
1 | // Right now, this thing does nothing with timeouts. It should.
2 | // We probably want to migrate this from being a stateless function to a stateful function.
3 | // Also really need to do a ton of work to make this more resilient around retries, etc.
4 | import { createReadStream } from 'node:fs';
5 | import fs from 'node:fs/promises';
6 |
7 | import chalk from 'chalk';
8 | import Mux from '@mux/mux-node';
9 | import { fetch as uFetch } from 'undici';
10 |
11 | import { updateAsset, Asset } from '../../assets.js';
12 | import { getVideoConfig } from '../../config.js';
13 | import log from '../../utils/logger.js';
14 | import { sleep } from '../../utils/utils.js';
15 | import { Queue } from '../../utils/queue.js';
16 |
17 | export type MuxMetadata = {
18 | uploadId?: string;
19 | assetId?: string;
20 | playbackId?: string;
21 | }
22 |
23 | // We don't want to blow things up immediately if Mux isn't configured,
24 | // but we also don't want to need to initialize it every time in situations like polling.
25 | // So we'll initialize it lazily but cache the instance.
26 | let mux: Mux;
27 | let queue: Queue;
28 | function initMux() {
29 | mux ??= new Mux();
30 | queue ??= new Queue();
31 | }
32 |
33 | async function pollForAssetReady(filePath: string, asset: Asset) {
34 | const providerMetadata: MuxMetadata | undefined = asset.providerMetadata?.mux;
35 |
36 | if (!providerMetadata?.assetId) {
37 | log.error('No assetId provided for asset.');
38 | console.error(asset);
39 | return;
40 | }
41 |
42 | initMux();
43 |
44 | const assetId = providerMetadata?.assetId;
45 | const muxAsset = await mux.video.assets.retrieve(assetId);
46 | const playbackId = muxAsset.playback_ids?.[0].id!;
47 |
48 | let updatedAsset: Asset = asset;
49 | if (providerMetadata?.playbackId !== playbackId) {
50 | // We can go ahead and update it here so we have the playback ID, even before the Asset is ready.
51 | updatedAsset = await updateAsset(filePath, {
52 | providerMetadata: {
53 | mux: {
54 | playbackId,
55 | }
56 | },
57 | });
58 | }
59 |
60 | if (muxAsset.status === 'errored') {
61 | log.error(log.label('Asset errored:'), filePath);
62 | log.space(chalk.gray('>'), log.label('Mux Asset ID:'), assetId);
63 |
64 | return updateAsset(filePath, {
65 | status: 'error',
66 | error: muxAsset.errors,
67 | });
68 | }
69 |
70 | if (muxAsset.status === 'ready') {
71 | let blurDataURL;
72 | try {
73 | blurDataURL = await createThumbHash(`https://image.mux.com/${playbackId}/thumbnail.webp?width=16&height=16`);
74 | } catch (e) {
75 | log.error('Error creating a thumbnail hash.');
76 | }
77 |
78 | log.success(log.label('Asset is ready:'), filePath);
79 | log.space(chalk.gray('>'), log.label('Playback ID:'), playbackId);
80 |
81 | return updateAsset(filePath, {
82 | status: 'ready',
83 | blurDataURL,
84 | providerMetadata: {
85 | mux: {
86 | playbackId,
87 | }
88 | },
89 | });
90 |
91 | // TODO: do we want to do something like `callHandlers('video.asset.ready', asset)` here? It'd be faking the webhook.
92 | } else {
93 | // We should also do something in here that allows us to complain loudly if the server is killed before we get here.
94 | await sleep(1000);
95 | return pollForAssetReady(filePath, updatedAsset);
96 | }
97 | }
98 |
99 | async function pollForUploadAsset(filePath: string, asset: Asset) {
100 | const providerMetadata: MuxMetadata | undefined = asset.providerMetadata?.mux;
101 |
102 | if (!providerMetadata?.uploadId) {
103 | log.error('No uploadId provided for asset.');
104 | console.error(asset);
105 | return;
106 | }
107 |
108 | initMux();
109 |
110 | const uploadId = providerMetadata?.uploadId;
111 | const muxUpload = await mux.video.uploads.retrieve(uploadId);
112 |
113 | if (muxUpload.asset_id) {
114 | log.info(log.label('Asset is processing:'), filePath);
115 | log.space(chalk.gray('>'), log.label('Mux Asset ID:'), muxUpload.asset_id);
116 |
117 | const processingAsset = await updateAsset(filePath, {
118 | status: 'processing',
119 | providerMetadata: {
120 | mux: {
121 | assetId: muxUpload.asset_id,
122 | }
123 | },
124 | });
125 |
126 | return pollForAssetReady(filePath, processingAsset);
127 | } else {
128 | // We should do something in here that allows us to complain loudly if the server is killed before we get here.
129 | await sleep(1000);
130 | return pollForUploadAsset(filePath, asset);
131 | }
132 | }
133 |
134 | async function createUploadURL() : Promise {
135 | try {
136 | const { providerConfig } = await getVideoConfig();
137 | const muxConfig = providerConfig.mux;
138 | // Create a direct upload url
139 | const upload = await mux.video.uploads.create({
140 | cors_origin: '*',
141 | new_asset_settings: {
142 | playback_policy: ['public'],
143 | video_quality: muxConfig?.videoQuality,
144 | },
145 | });
146 | return upload
147 | } catch (e) {
148 | if (e instanceof Error && 'status' in e && e.status === 401) {
149 | log.error("Unauthorized request. Check that your MUX_TOKEN_ID and MUX_TOKEN_SECRET credentials are valid.");
150 | } else {
151 | log.error('Error creating a Mux Direct Upload');
152 | console.error(e);
153 | }
154 | return undefined;
155 | }
156 | }
157 |
158 | export async function uploadLocalFile(asset: Asset) {
159 | const filePath = asset.originalFilePath;
160 |
161 | if (!filePath) {
162 | log.error('No filePath provided for asset.');
163 | console.error(asset);
164 | return;
165 | }
166 |
167 | initMux();
168 |
169 | if (asset.status === 'ready') {
170 | return;
171 | } else if (asset.status === 'processing') {
172 | log.info(log.label('Asset is already processing. Polling for completion:'), filePath);
173 | return pollForAssetReady(filePath, asset);
174 | } else if (asset.status === 'uploading') {
175 | // Right now this re-starts the upload from the beginning.
176 | // We should probably do something smarter here.
177 | log.info(log.label('Resuming upload:'), filePath);
178 | }
179 |
180 | // Handle imported remote videos.
181 | if (filePath && /^https?:\/\//.test(filePath)) {
182 | return uploadRequestedFile(asset);
183 | }
184 |
185 | const upload: Mux.Video.Uploads.Upload | undefined = await queue.enqueue(() => createUploadURL());
186 | if (!upload) {
187 | return;
188 | }
189 |
190 | await updateAsset(filePath, {
191 | status: 'uploading',
192 | providerMetadata: {
193 | mux: {
194 | uploadId: upload.id as string, // more typecasting while we use the beta mux sdk
195 | }
196 | },
197 | });
198 |
199 | const fileStats = await fs.stat(filePath);
200 | const stream = createReadStream(filePath);
201 |
202 | log.info(log.label('Uploading file:'), `${filePath} (${fileStats.size} bytes)`);
203 |
204 | try {
205 | // I'm having to do some annoying, defensive typecasting here becuase we need to fix some OAS spec stuff.
206 | await uFetch(upload.url as string, {
207 | method: 'PUT',
208 | // @ts-ignore
209 | body: stream,
210 | duplex: 'half',
211 | });
212 | stream.close();
213 | } catch (e) {
214 | log.error('Error uploading to the Mux upload URL');
215 | console.error(e);
216 | return;
217 | }
218 |
219 | log.success(log.label('File uploaded:'), `${filePath} (${fileStats.size} bytes)`);
220 |
221 | const processingAsset = await updateAsset(filePath, {
222 | status: 'processing',
223 | });
224 |
225 | return pollForUploadAsset(filePath, processingAsset);
226 | }
227 |
228 | export async function uploadRequestedFile(asset: Asset) {
229 | const filePath = asset.originalFilePath;
230 |
231 | if (!filePath) {
232 | log.error('No URL provided for asset.');
233 | console.error(asset);
234 | return;
235 | }
236 |
237 | initMux();
238 |
239 | if (asset.status === 'ready') {
240 | return;
241 | } else if (asset.status === 'processing') {
242 | log.info(log.label('Asset is already processing. Polling for completion:'), filePath);
243 | return pollForAssetReady(filePath, asset);
244 | }
245 |
246 | const { providerConfig } = await getVideoConfig();
247 | const muxConfig = providerConfig.mux;
248 |
249 | const assetObj = await mux.video.assets.create({
250 | input: [{
251 | url: filePath
252 | }],
253 | playback_policy: ['public'],
254 | video_quality: muxConfig?.videoQuality,
255 | });
256 |
257 | log.info(log.label('Asset is processing:'), filePath);
258 | log.space(chalk.gray('>'), log.label('Mux Asset ID:'), assetObj.id);
259 |
260 | const processingAsset = await updateAsset(filePath, {
261 | status: 'processing',
262 | providerMetadata: {
263 | mux: {
264 | assetId: assetObj.id!,
265 | }
266 | },
267 | });
268 |
269 | return pollForAssetReady(filePath, processingAsset);
270 | }
271 |
272 | export async function createThumbHash(imgUrl: string) {
273 | const response = await uFetch(imgUrl);
274 | const buffer = await response.arrayBuffer();
275 | const base64String = btoa(String.fromCharCode(...new Uint8Array(buffer)));
276 | return `data:image/webp;base64,${base64String}`;
277 | }
278 |
--------------------------------------------------------------------------------
/src/providers/mux/transformer.ts:
--------------------------------------------------------------------------------
1 | import type { Asset } from '../../assets.js';
2 |
3 | type Props = {
4 | customDomain?: string;
5 | thumbnailTime?: number;
6 | startTime?: number;
7 | tokens?: { thumbnail?: string };
8 | };
9 |
10 | type PosterProps = {
11 | customDomain?: string;
12 | thumbnailTime?: number;
13 | token?: string;
14 | width?: number | string;
15 | };
16 |
17 | const MUX_VIDEO_DOMAIN = 'mux.com';
18 |
19 | export function transform(asset: Asset, props?: Props) {
20 | const playbackId = getPlaybackId(asset);
21 | if (!playbackId) return asset;
22 |
23 | const thumbnailTime =
24 | asset.providerMetadata?.mux?.thumbnailTime ?? props?.thumbnailTime ?? props?.startTime;
25 |
26 | const transformedAsset: Asset = {
27 | ...asset,
28 |
29 | sources: [{
30 | src: `https://stream.${props?.customDomain ?? MUX_VIDEO_DOMAIN}/${playbackId}.m3u8`,
31 | type: 'application/x-mpegURL'
32 | }],
33 |
34 | poster: getPosterURLFromPlaybackId(playbackId, {
35 | thumbnailTime,
36 | customDomain: props?.customDomain,
37 | token: props?.tokens?.thumbnail,
38 | }),
39 | };
40 |
41 | if (thumbnailTime >= 0) {
42 | transformedAsset.thumbnailTime = thumbnailTime;
43 | }
44 |
45 | return transformedAsset;
46 | }
47 |
48 | export function getPlaybackId(asset: Asset): string | undefined {
49 | // Fallback to asset.externalIds for backwards compatibility with older assets.
50 | const providerMetadata = asset.providerMetadata?.mux ?? asset.externalIds;
51 | return providerMetadata?.playbackId;
52 | }
53 |
54 | export const getPosterURLFromPlaybackId = (
55 | playbackId?: string,
56 | { token, thumbnailTime, width, customDomain = MUX_VIDEO_DOMAIN }: PosterProps = {}
57 | ) => {
58 | // NOTE: thumbnailTime is not supported when using a signedURL/token. Remove under these cases. (CJP)
59 | const time = token == null ? thumbnailTime : undefined;
60 | const { aud } = parseJwt(token);
61 |
62 | if (token && aud !== 't') {
63 | return;
64 | }
65 |
66 | return `https://image.${customDomain}/${playbackId}/thumbnail.webp${toQuery({
67 | token,
68 | time,
69 | width,
70 | })}`;
71 | };
72 |
73 | function toQuery(obj: Record) {
74 | const params = toParams(obj).toString();
75 | return params ? '?' + params : '';
76 | }
77 |
78 | function toParams(obj: Record) {
79 | const params: Record = {};
80 | for (const key in obj) {
81 | if (obj[key] != null) params[key] = obj[key];
82 | }
83 | return new URLSearchParams(params);
84 | }
85 |
86 | function parseJwt(token: string | undefined) {
87 | const base64Url = (token ?? '').split('.')[1];
88 |
89 | // exit early on invalid value
90 | if (!base64Url) return {};
91 |
92 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
93 | const jsonPayload = decodeURIComponent(
94 | atob(base64)
95 | .split('')
96 | .map(function (c) {
97 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
98 | })
99 | .join('')
100 | );
101 | return JSON.parse(jsonPayload);
102 | }
103 |
--------------------------------------------------------------------------------
/src/providers/providers.ts:
--------------------------------------------------------------------------------
1 | export * as mux from './mux/provider.js';
2 | export * as vercelBlob from './vercel-blob/provider.js';
3 | export * as backblaze from './backblaze/provider.js';
4 | export * as amazonS3 from './amazon-s3/provider.js';
5 | export * as cloudflareR2 from './cloudflare-r2/provider.js'
6 |
--------------------------------------------------------------------------------
/src/providers/transformers.ts:
--------------------------------------------------------------------------------
1 | export * as mux from './mux/transformer.js';
2 | export * as vercelBlob from './vercel-blob/transformer.js';
3 | export * as backblaze from './backblaze/transformer.js';
4 | export * as amazonS3 from './amazon-s3/transformer.js';
5 | export * as cloudflareR2 from './cloudflare-r2/transformer.js';
6 |
--------------------------------------------------------------------------------
/src/providers/vercel-blob/provider.ts:
--------------------------------------------------------------------------------
1 | import { ReadStream, createReadStream } from 'node:fs';
2 | import fs from 'node:fs/promises';
3 | import { fetch as uFetch } from 'undici';
4 | import { put } from '@vercel/blob';
5 | import chalk from 'chalk';
6 |
7 | import { updateAsset, Asset } from '../../assets.js';
8 | import { createAssetKey } from '../../utils/provider.js';
9 | import { isRemote } from '../../utils/utils.js';
10 | import log from '../../utils/logger.js';
11 |
12 | export const config = {
13 | runtime: 'edge',
14 | };
15 |
16 | export type VercelBlobMetadata = {
17 | url?: string;
18 | contentType?: string;
19 | }
20 |
21 | export async function uploadLocalFile(asset: Asset) {
22 | const filePath = asset.originalFilePath;
23 |
24 | if (!filePath) {
25 | log.error('No filePath provided for asset.');
26 | console.error(asset);
27 | return;
28 | }
29 |
30 | // Handle imported remote videos.
31 | if (isRemote(filePath)) {
32 | return uploadRequestedFile(asset);
33 | }
34 |
35 | if (asset.status === 'ready') {
36 | return;
37 | } else if (asset.status === 'uploading') {
38 | // Right now this re-starts the upload from the beginning.
39 | // We should probably do something smarter here.
40 | log.info(log.label('Resuming upload:'), filePath);
41 | }
42 |
43 | await updateAsset(filePath, {
44 | status: 'uploading'
45 | });
46 |
47 | const fileStats = await fs.stat(filePath);
48 | const stream = createReadStream(filePath);
49 |
50 | return putAsset(filePath, fileStats.size, stream);
51 | }
52 |
53 | export async function uploadRequestedFile(asset: Asset) {
54 | const filePath = asset.originalFilePath;
55 |
56 | if (!filePath) {
57 | log.error('No URL provided for asset.');
58 | console.error(asset);
59 | return;
60 | }
61 |
62 | if (asset.status === 'ready') {
63 | return;
64 | }
65 |
66 | await updateAsset(filePath, {
67 | status: 'uploading'
68 | });
69 |
70 | const response = await uFetch(filePath);
71 | const size = Number(response.headers.get('content-length'));
72 | const stream = response.body;
73 |
74 | if (!stream) {
75 | log.error('Error fetching the requested file:', filePath);
76 | return;
77 | }
78 |
79 | return putAsset(filePath, size, stream as ReadableStream);
80 | }
81 |
82 | async function putAsset(filePath: string, size: number, stream: ReadStream | ReadableStream) {
83 | log.info(log.label('Uploading file:'), `${filePath} (${size} bytes)`);
84 |
85 | let key;
86 | let blob;
87 | try {
88 | key = await createAssetKey(filePath, 'vercel-blob');
89 | blob = await put(key, stream, { access: 'public' });
90 |
91 | if (stream instanceof ReadStream) {
92 | stream.close();
93 | }
94 | } catch (e) {
95 | log.error('Error uploading to Vercel Blob');
96 | console.error(e);
97 | return;
98 | }
99 |
100 | log.success(log.label('File uploaded:'), `${filePath} (${size} bytes)`);
101 | log.space(chalk.gray('>'), log.label('URL:'), blob.url);
102 |
103 | const updatedAsset = await updateAsset(filePath, {
104 | status: 'ready',
105 | providerMetadata: {
106 | 'vercel-blob': {
107 | key,
108 | url: blob.url,
109 | contentType: blob.contentType,
110 | } as VercelBlobMetadata
111 | },
112 | });
113 |
114 | const url = updatedAsset.sources?.[0].src;
115 | log.space(chalk.gray('>'), log.label('URL:'), url);
116 |
117 | return updatedAsset;
118 | }
119 |
--------------------------------------------------------------------------------
/src/providers/vercel-blob/transformer.ts:
--------------------------------------------------------------------------------
1 | import type { Asset, AssetSource } from '../../assets.js';
2 |
3 | export function transform(asset: Asset) {
4 | // Fallback to asset.externalIds for backwards compatibility with older assets.
5 | const providerDetails = asset.providerMetadata?.['vercel-blob'] ?? asset.externalIds;
6 | if (!providerDetails) return asset;
7 |
8 | const source: AssetSource = {
9 | src: providerDetails.url
10 | };
11 |
12 | if (providerDetails.contentType) {
13 | source.type = providerDetails.contentType;
14 | }
15 |
16 | return {
17 | ...asset,
18 | sources: [source],
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/request-handler.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next';
2 | import { callHandler } from './process.js';
3 | import { createAsset, getAsset } from './assets.js';
4 | import { getVideoConfig } from './config.js';
5 |
6 | // App Router
7 | export async function GET(request: Request) {
8 | const { searchParams } = new URL(request.url)
9 | const url = searchParams.get('url');
10 | const { status, data } = await getRequest(url);
11 | // @ts-ignore - Response.json() is only valid from TypeScript 5.2
12 | return Response.json(data, { status });
13 | }
14 |
15 | // App Router
16 | export async function POST(request: Request) {
17 | const { url } = await request.json();
18 | const { status, data } = await postRequest(url);
19 | // @ts-ignore - Response.json() is only valid from TypeScript 5.2
20 | return Response.json(data, { status });
21 | }
22 |
23 | // Pages Router
24 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
25 |
26 | if (req.method === 'POST') {
27 | const { status, data } = await postRequest(String(req.body.url));
28 | res.status(status).json(data);
29 | return;
30 | }
31 |
32 | const { status, data } = await getRequest(String(req.query.url));
33 | res.status(status).json(data);
34 | }
35 |
36 | async function getRequest(url?: string | null) {
37 | if (!url) {
38 | return {
39 | status: 400,
40 | data: { error: 'url parameter is required' }
41 | };
42 | }
43 |
44 | let asset;
45 | try {
46 | asset = await getAsset(url);
47 | } catch {
48 |
49 | // In dev mode we try to create the asset if it doesn't exist on a GET request.
50 | const isDevMode = process.env.NODE_ENV === 'development';
51 |
52 | if (isDevMode) {
53 | asset = await createAsset(url);
54 |
55 | if (asset) {
56 | const videoConfig = await getVideoConfig();
57 | await callHandler('request.video.added', asset, videoConfig);
58 |
59 | return { status: 200, data: asset };
60 | } else {
61 | return { status: 500, data: { error: 'could not create asset' } };
62 | }
63 | }
64 |
65 | return { status: 404, data: { error: 'asset not found' } };
66 | }
67 |
68 | return { status: 200, data: asset };
69 | }
70 |
71 | async function postRequest(url?: string | null) {
72 | if (!url) {
73 | return {
74 | status: 400,
75 | data: { error: 'url parameter is required' }
76 | };
77 | }
78 |
79 | let asset;
80 | try {
81 | asset = await createAsset(url);
82 |
83 | if (!asset) {
84 | return { status: 500, data: { error: 'could not create asset' } };
85 | }
86 |
87 | const videoConfig = await getVideoConfig();
88 | await callHandler('request.video.added', asset, videoConfig);
89 |
90 | return { status: 200, data: asset };
91 | } catch {
92 | return { status: 500, data: { error: 'could not create asset' } };
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/setup-next-video.ts:
--------------------------------------------------------------------------------
1 | import { setVideoConfig } from './config.js';
2 | import handler, { GET, POST } from './request-handler.js';
3 | import { withNextVideo as withNextVideoInternal } from './with-next-video.js';
4 | import type { VideoConfig } from './config.js';
5 | import type { NextConfig } from 'next';
6 |
7 | export function NextVideo(config?: VideoConfig) {
8 | setVideoConfig(config);
9 |
10 | const withNextVideo = (nextConfig: NextConfig) => {
11 | return withNextVideoInternal(nextConfig, config);
12 | };
13 |
14 | return {
15 | GET,
16 | POST,
17 | handler,
18 | withNextVideo,
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | /* c8 ignore start */
2 | import chalk from 'chalk';
3 |
4 | type logType = 'log' | 'error';
5 | export type Logger = (...messages: any[]) => void;
6 |
7 | export function base(type: logType, ...messages: string[]) {
8 | console[type](...messages);
9 | }
10 |
11 | export function log(...messages: any[]) {
12 | base('log', ...messages);
13 | }
14 |
15 | export function info(...messages: any[]) {
16 | base('log', chalk.blue.bold('-'), ...messages);
17 | }
18 |
19 | export function success(...messages: any[]) {
20 | base('log', chalk.green.bold('✓'), ...messages);
21 | }
22 |
23 | export function add(...messages: any[]) {
24 | base('log', chalk.blue.green('+'), ...messages);
25 | }
26 |
27 | export function warning(...messages: any[]) {
28 | base('log', chalk.yellow.bold('⚠'), ...messages);
29 | }
30 |
31 | export function error(...messages: any[]) {
32 | base('error', chalk.red.bold('✗'), ...messages);
33 | base('log', '');
34 | }
35 |
36 | export function space(...messages: any[]) {
37 | base('log', ' ', ...messages);
38 | }
39 |
40 | export function label(detail: string) {
41 | return chalk.magenta.bold(detail);
42 | }
43 |
44 | export default {
45 | base,
46 | log,
47 | info,
48 | success,
49 | add,
50 | warning,
51 | error,
52 | space,
53 | label,
54 | };
55 | /* c8 ignore stop */
56 |
--------------------------------------------------------------------------------
/src/utils/provider.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'node:path';
2 |
3 | import { getVideoConfig } from '../config.js';
4 | import { isRemote } from './utils.js';
5 |
6 | import type { ProviderConfig } from '../config.js';
7 |
8 | export async function createAssetKey(filePathOrURL: string, provider: keyof ProviderConfig) {
9 | const { folder, providerConfig } = await getVideoConfig();
10 | const config = providerConfig[provider];
11 | const { generateAssetKey = defaultGenerateAssetKey } = config ?? {};
12 | return generateAssetKey(filePathOrURL, folder);
13 | }
14 |
15 | function defaultGenerateAssetKey(filePathOrURL: string, folder: string) {
16 | // By default local imports keep the same local folder structure.
17 | if (!isRemote(filePathOrURL)) return filePathOrURL;
18 |
19 | const url = new URL(filePathOrURL);
20 |
21 | // Remote imports are stored in the configured videos folder with just the file name.
22 | // This could easily lead to collisions if the same file name is used in different
23 | // remote sources. There are many ways to generate unique asset keys from remote sources,
24 | // so we leave this up to the user to configure if needed.
25 | return path.posix.join(folder, path.basename(decodeURIComponent(url.pathname)));
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/queue.ts:
--------------------------------------------------------------------------------
1 | export interface QueueItem {
2 | fn: () => Promise;
3 | resolve: (value: T) => void;
4 | reject: (reason?: any) => void;
5 | }
6 |
7 | export class Queue {
8 | private lastRequestTime: number | null = null;
9 | private requestQueue: QueueItem[] = [];
10 | private processing = false;
11 | private delayMs: number;
12 |
13 | constructor(delayMs: number = 1000) {
14 | this.delayMs = delayMs;
15 | }
16 |
17 | public async enqueue(fn: () => Promise): Promise {
18 | return new Promise((resolve, reject) => {
19 | this.requestQueue.push({ fn, resolve, reject });
20 |
21 | if (!this.processing) {
22 | this.processNext();
23 | }
24 | });
25 | }
26 |
27 | private processNext() {
28 | if (this.requestQueue.length === 0) {
29 | this.processing = false;
30 | return;
31 | }
32 |
33 | this.processing = true;
34 |
35 | if (this.lastRequestTime !== null) {
36 | const elapsed = Date.now() - this.lastRequestTime;
37 | const waitTime = this.delayMs - elapsed;
38 |
39 | if (waitTime > 0) {
40 | setTimeout(() => this.executeNextRequest(), waitTime);
41 | } else {
42 | this.executeNextRequest();
43 | }
44 | } else {
45 | this.executeNextRequest();
46 | }
47 | }
48 |
49 | private executeNextRequest() {
50 | const item = this.requestQueue.shift();
51 |
52 | if (!item) {
53 | this.processing = false;
54 | return;
55 | }
56 |
57 | const { fn, resolve, reject } = item;
58 |
59 | fn()
60 | .then((result) => {
61 | resolve(result);
62 | })
63 | .catch((err) => {
64 | reject(err);
65 | })
66 | .finally(() => {
67 | this.lastRequestTime = Date.now();
68 | this.processNext();
69 | });
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/utils/r2.ts:
--------------------------------------------------------------------------------
1 | import { request } from 'undici';
2 |
3 | interface CloudflareR2PolicyResponse {
4 | success: boolean;
5 | errors: Array<{ code: number; message: string }>;
6 | messages: Array<{ code: number; message: string }>;
7 | result: {
8 | publicId: string;
9 | onlyViaCnames: string[];
10 | };
11 | }
12 |
13 | export async function publicAccessR2Bucket(
14 | accountId: string,
15 | bucketName: string,
16 | apiToken: string
17 | ): Promise {
18 | const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucketName}/policy?access=PublicUrlAndCnames`;
19 |
20 | try {
21 | const { statusCode, body } = await request(url, {
22 | method: 'PUT',
23 | headers: {
24 | Authorization: `Bearer ${apiToken}`,
25 | 'Content-Type': 'application/json',
26 | },
27 | });
28 |
29 | const responseBody: CloudflareR2PolicyResponse = (await body.json()) as CloudflareR2PolicyResponse;
30 |
31 | if (statusCode !== 200 || !responseBody.success) {
32 | throw new Error(
33 | `Failed to set public access. Status code: ${statusCode}, Error details: ${JSON.stringify(responseBody.errors)}`
34 | );
35 | }
36 |
37 | if (responseBody.result.onlyViaCnames.length > 0) {
38 | return responseBody.result.onlyViaCnames[0];
39 | } else {
40 | return `${responseBody.result.publicId}.r2.dev`;
41 | }
42 | } catch (error) {
43 | throw new Error(`Error setting public access: ${(error as Error).message}`);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/utils/s3.ts:
--------------------------------------------------------------------------------
1 | /* c8 ignore start */
2 | import {
3 | S3Client,
4 | PutBucketCorsCommand,
5 | CreateBucketCommand,
6 | PutObjectCommand,
7 | ListBucketsCommand,
8 | DeletePublicAccessBlockCommand,
9 | PutBucketAclCommand,
10 | } from '@aws-sdk/client-s3';
11 |
12 | export async function findBucket(s3: S3Client, callbackFn: (bucket: { Name?: string }) => boolean | void) {
13 | const { Buckets } = await s3.send(new ListBucketsCommand({}));
14 | return Buckets?.find(callbackFn);
15 | }
16 |
17 | export function createBucket(s3: S3Client, bucketName: string, input?: Partial) {
18 | return s3.send(new CreateBucketCommand({
19 | Bucket: bucketName,
20 | ...input
21 | }));
22 | }
23 |
24 | export async function putBucketAcl(s3: S3Client, bucketName: string, input?: Partial) {
25 | // Remove the public access block that is created by default.
26 | // https://aws.amazon.com/blogs/aws/heads-up-amazon-s3-security-changes-are-coming-in-april-of-2023/
27 | await s3.send(new DeletePublicAccessBlockCommand({
28 | Bucket: bucketName
29 | }));
30 |
31 | return s3.send(new PutBucketAclCommand({
32 | Bucket: bucketName,
33 | ACL: input?.ACL ?? 'public-read',
34 | ...input,
35 | }));
36 | }
37 |
38 | export function putObject(s3: S3Client, input: PutObjectCommand['input']) {
39 | return s3.send(new PutObjectCommand(input));
40 | }
41 |
42 | export function putBucketCors(s3: S3Client, bucketName: string) {
43 | return s3.send(new PutBucketCorsCommand({
44 | Bucket: bucketName,
45 | CORSConfiguration: {
46 | CORSRules: [
47 | {
48 | // Allow all headers to be sent to this bucket.
49 | AllowedHeaders: ['*'],
50 | // Allow only GET and PUT methods to be sent to this bucket.
51 | AllowedMethods: ['GET', 'PUT'],
52 | // Allow only requests from the specified origin.
53 | AllowedOrigins: ['*'],
54 | // Allow the entity tag (ETag) header to be returned in the response. The ETag header
55 | // The entity tag represents a specific version of the object. The ETag reflects
56 | // changes only to the contents of an object, not its metadata.
57 | ExposeHeaders: ['ETag'],
58 | // How long the requesting browser should cache the preflight response. After
59 | // this time, the preflight request will have to be made again.
60 | MaxAgeSeconds: 3600,
61 | },
62 | ],
63 | },
64 | }));
65 | }
66 | /* c8 ignore stop */
67 |
--------------------------------------------------------------------------------
/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import resolve from 'resolve';
3 |
4 | export function getPackageVersion(packageName: string) {
5 | const packageJsonPath = resolvePackageJson(packageName);
6 | if (packageJsonPath) {
7 | try {
8 | const packageJson: { version: string } = JSON.parse(
9 | fs.readFileSync(packageJsonPath, { encoding: 'utf-8' }),
10 | );
11 | return packageJson.version;
12 | } catch {
13 | // noop
14 | }
15 | }
16 | return undefined;
17 | }
18 |
19 | function resolvePackageJson(packageName: string) {
20 | try {
21 | return resolve.sync(`${packageName}/package.json`, {
22 | basedir: process.cwd(),
23 | preserveSymlinks: true,
24 | });
25 | } catch {
26 | return undefined;
27 | }
28 | }
29 |
30 | export function sleep(ms: number) {
31 | return new Promise((resolve) => setTimeout(resolve, ms));
32 | }
33 |
34 | export function camelCase(name: string) {
35 | return name.replace(/[-_]([a-z])/g, ($0, $1) => $1.toUpperCase());
36 | }
37 |
38 | export function isRemote(filePath: string) {
39 | return /^https?:\/\//.test(filePath);
40 | }
41 |
42 | export function toSafePath(str: string) {
43 | return str
44 | .replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '')
45 | .replace(/[^a-zA-Z0-9._-]+/g, '_');
46 | }
47 |
48 | /**
49 | * Performs a deep merge of objects and returns a new object.
50 | * Does not modify objects (immutable) and merges arrays via concatenation.
51 | */
52 | export function deepMerge(...objects: any[]) {
53 | const result: any = {};
54 | for (const obj of objects) {
55 | for (const key in obj) {
56 | const existing = result[key];
57 | const val = obj[key];
58 | if (Array.isArray(val) && Array.isArray(existing)) {
59 | result[key] = existing.concat(...val);
60 | } else if (isObject(val) && isObject(existing)) {
61 | result[key] = deepMerge(existing, val);
62 | } else {
63 | result[key] = val;
64 | }
65 | }
66 | }
67 | return result;
68 | }
69 |
70 | export function isObject(item: any) {
71 | return (item && typeof item === 'object' && !Array.isArray(item));
72 | }
73 |
--------------------------------------------------------------------------------
/src/video-handler.ts:
--------------------------------------------------------------------------------
1 | type VideoHandlerCallback = (event: any, ...args: any[]) => Promise;
2 |
3 | interface Handlers {
4 | [key: string]: VideoHandlerCallback;
5 | }
6 |
7 | export interface HandlerConfig {
8 | timeout?: number;
9 | [key: string]: any;
10 | }
11 |
12 | export const HANDLERS: Handlers = {};
13 |
14 | const DEFAULT_HANDLER_CONFIG = {
15 | provider: 'mux',
16 | };
17 |
18 | export async function callHandler(event: string, data: any, config: HandlerConfig = {}): Promise {
19 | const mergedConfig = { ...DEFAULT_HANDLER_CONFIG, ...config };
20 | const handler = HANDLERS[event];
21 |
22 | if (!handler) {
23 | return;
24 | }
25 | // Return values in an async function are automatically wrapped in a Promise.
26 | return handler(data, mergedConfig);
27 | }
28 |
29 | export function videoHandler(event: string, callback: VideoHandlerCallback) {
30 | HANDLERS[event] = callback;
31 | // Return values in an async function are automatically wrapped in a Promise.
32 | return async (event: any) => callback(event);
33 | }
34 |
--------------------------------------------------------------------------------
/src/webpack/video-json-loader.ts:
--------------------------------------------------------------------------------
1 | import { fetch as uFetch } from 'undici';
2 |
3 | export default async function loader(this: any, source: string) {
4 | const params = new URLSearchParams(this.resourceQuery);
5 | const thumbnailTime = params.get('thumbnailTime');
6 |
7 | let asset;
8 | try {
9 | asset = JSON.parse(source);
10 |
11 | if (asset.poster && asset.provider === 'mux' && thumbnailTime && parseInt(thumbnailTime) >= 0) {
12 | // This is added during build time, not stored in the JSON asset.
13 | asset.providerMetadata.mux.thumbnailTime = thumbnailTime;
14 |
15 | const poster = new URL(asset.poster);
16 |
17 | poster.searchParams.set('time', thumbnailTime);
18 | asset.poster = `${poster}`;
19 |
20 | poster.searchParams.set('width', '16');
21 | poster.searchParams.set('height', '16');
22 | asset.blurDataURL = await createThumbHash(`${poster}`);
23 | }
24 | } catch {
25 | asset = { status: 'error', message: 'Invalid JSON' };
26 | }
27 |
28 | return `${JSON.stringify(asset)}`;
29 | }
30 |
31 | export async function createThumbHash(imgUrl: string) {
32 | const response = await uFetch(imgUrl);
33 | const buffer = await response.arrayBuffer();
34 | const base64String = btoa(String.fromCharCode(...new Uint8Array(buffer)));
35 | return `data:image/webp;base64,${base64String}`;
36 | }
37 |
--------------------------------------------------------------------------------
/src/webpack/video-raw-loader.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { readFile } from 'node:fs/promises';
3 | import { createAsset, getAssetConfigPath } from '../assets.js';
4 |
5 |
6 | // https://webpack.js.org/api/loaders#raw-loader
7 | export const raw = true;
8 |
9 | export default async function loader(this: any) {
10 | const params = new URLSearchParams(this.resourceQuery);
11 | // Don't include the thumbnailTime URL param in the asset path.
12 | params.delete('thumbnailTime');
13 |
14 | const query = params.size ? `?${params}` : '';
15 | const importPath = `${this.resourcePath}${query}`;
16 | const assetPath = path.resolve(await getAssetConfigPath(importPath));
17 |
18 | this.addDependency(assetPath);
19 |
20 | let asset;
21 | try {
22 | asset = await readFile(assetPath, 'utf-8');
23 | } catch {
24 | asset = JSON.stringify(await createAsset(importPath, {
25 | status: 'sourced'
26 | }));
27 | }
28 | return `${asset}`;
29 | }
30 |
--------------------------------------------------------------------------------
/src/with-next-video.ts:
--------------------------------------------------------------------------------
1 | import symlinkDir from 'symlink-dir';
2 | import { join, dirname } from 'node:path';
3 | import fs from 'node:fs';
4 | import { env } from 'node:process';
5 | import { fileURLToPath } from 'node:url';
6 | import logger from './utils/logger.js';
7 | import { getPackageVersion } from './utils/utils.js';
8 | import { setVideoConfig } from './config.js';
9 | import type { VideoConfig } from './config.js';
10 |
11 | let hasWarned = false;
12 |
13 | export function withNextVideo(nextConfig: any, videoConfig?: VideoConfig) {
14 | const videoConfigComplete = setVideoConfig(videoConfig);
15 | const { path, folder, provider } = videoConfigComplete;
16 |
17 | // env VARS have to be set before the async function return!!
18 |
19 | // Don't use `process.env` here because Next.js replaces public env vars during build.
20 | env['NEXT_PUBLIC_VIDEO_OPTS'] = JSON.stringify({ path, folder, provider });
21 |
22 | // We should probably switch to using `phase` here, just a bit concerned about backwards compatibility.
23 | if (process.argv[2] === 'dev') {
24 | // Don't use `process.env` here because Next.js replaces public env vars during build.
25 | env['NEXT_PUBLIC_DEV_VIDEO_OPTS'] = JSON.stringify({ path, folder, provider });
26 | }
27 |
28 | if (typeof nextConfig === 'function') {
29 | return async (...args: any[]) => {
30 | const nextConfigResult = await nextConfig(...args);
31 | return withNextVideo(nextConfigResult, videoConfig);
32 | };
33 | }
34 |
35 | // We should probably switch to using `phase` here, just a bit concerned about backwards compatibility.
36 | if (process.argv[2] === 'dev') {
37 | const VIDEOS_PATH = join(process.cwd(), folder);
38 | const TMP_PUBLIC_VIDEOS_PATH = join(process.cwd(), 'public', `_next-video`);
39 |
40 | symlinkDir(VIDEOS_PATH, TMP_PUBLIC_VIDEOS_PATH);
41 |
42 | process.on('exit', async () => {
43 | fs.unlinkSync(TMP_PUBLIC_VIDEOS_PATH);
44 | });
45 | }
46 |
47 | const nextVersion = getPackageVersion('next');
48 |
49 | if (nextVersion && nextVersion.startsWith('15.')) {
50 | nextConfig.outputFileTracingIncludes = {
51 | ...nextConfig.outputFileTracingIncludes,
52 | [path]: [`./${folder}/**/*.json`],
53 | };
54 | } else {
55 | const experimental = { ...nextConfig.experimental };
56 | experimental.outputFileTracingIncludes = {
57 | ...experimental.outputFileTracingIncludes,
58 | [path]: [`./${folder}/**/*.json`],
59 | };
60 | nextConfig.experimental = experimental;
61 | }
62 |
63 | if (!hasWarned && process.env.TURBOPACK && !process.env.NEXT_VIDEO_SUPPRESS_TURBOPACK_WARNING) {
64 | hasWarned = true;
65 |
66 | const nextVideoVersion = getPackageVersion('next-video');
67 |
68 | logger.space(logger.label(`▶︎ next-video ${nextVideoVersion}\n`));
69 | logger.warning(
70 | `You are using next-video with \`next ${
71 | process.env.NODE_ENV === 'development' ? 'dev' : 'build'
72 | } --turbo\`. next-video doesn't yet fully support Turbopack.\n We recommend temporarily removing the \`--turbo\` flag for use with next-video.\n`
73 | );
74 | logger.warning(
75 | `Follow this issue for progress on next-video + Turbopack: https://github.com/muxinc/next-video/issues/266.\n (You can suppress this warning by setting NEXT_VIDEO_SUPPRESS_TURBOPACK_WARNING=1 as environment variable)\n`
76 | );
77 | }
78 |
79 | return Object.assign({}, nextConfig, {
80 | webpack(config: any, options: any) {
81 | if (!options.defaultLoaders) {
82 | throw new Error(
83 | 'This plugin is not compatible with Next.js versions below 5.0.0 https://err.sh/next-plugins/upgrade'
84 | );
85 | }
86 |
87 | config.infrastructureLogging = {
88 | ...config.infrastructureLogging,
89 | // Silence warning about dynamic import of next.config file.
90 | // > [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /next-video/dist/config.js for build dependencies failed at 'import(fileUrl.
91 | // > Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
92 | level: 'error',
93 | };
94 |
95 | config.experiments.buildHttp = {
96 | allowedUris: [
97 | /https?:\/\/.*\.(mp4|webm|mkv|ogg|ogv|wmv|avi|mov|flv|m4v|3gp)\??(?:&?[^=&]*=[^=&]*)*$/,
98 | ...(config.experiments.buildHttp?.allowedUris ?? []),
99 | ],
100 | ...(config.experiments.buildHttp || {}),
101 | // Disable cache to prevent Webpack from downloading the remote sources.
102 | cacheLocation: false,
103 | };
104 |
105 | const scriptDir =
106 | typeof __dirname === 'string'
107 | ? __dirname // CJS module
108 | : dirname(fileURLToPath(import.meta.url)); // ESM module
109 |
110 | config.module.rules.push(
111 | {
112 | test: /\.(mp4|webm|mkv|ogg|ogv|wmv|avi|mov|flv|m4v|3gp)\??(?:&?[^=&]*=[^=&]*)*$/,
113 | use: [
114 | {
115 | loader: join(scriptDir, 'webpack/video-json-loader.js'),
116 | },
117 | {
118 | loader: join(scriptDir, 'webpack/video-raw-loader.js'),
119 | },
120 | ],
121 | type: 'json',
122 | },
123 | {
124 | test: /\.(mp4|webm|mkv|ogg|ogv|wmv|avi|mov|flv|m4v|3gp)\.json\??(?:&?[^=&]*=[^=&]*)*$/,
125 | use: [
126 | {
127 | loader: join(scriptDir, 'webpack/video-json-loader.js'),
128 | },
129 | ],
130 | type: 'json',
131 | }
132 | );
133 |
134 | if (typeof nextConfig.webpack === 'function') {
135 | return nextConfig.webpack(config, options);
136 | }
137 |
138 | return config;
139 | },
140 | });
141 | }
142 |
--------------------------------------------------------------------------------
/tests/cli/lib/json-configs.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { describe, it } from 'node:test';
3 |
4 | import { checkPackageJsonForNextVideo, updateTSConfigFileContent } from '../../../src/cli/lib/json-configs.js';
5 |
6 | describe('json-configs', () => {
7 | describe('updateTSConfigFileContent', () => {
8 | it('should add video.d.ts to the include array inline', () => {
9 | const tsContents = `{
10 | "compilerOptions": {
11 | "target": "es2015"
12 | },
13 | "include": ["src/**/*", "foo.d.ts"]
14 | }`;
15 |
16 | const expected = `{
17 | "compilerOptions": {
18 | "target": "es2015"
19 | },
20 | "include": ["video.d.ts", "src/**/*", "foo.d.ts"]
21 | }`;
22 |
23 | const actual = updateTSConfigFileContent(tsContents);
24 |
25 | assert.equal(actual, expected);
26 | });
27 |
28 | it('should add video.d.ts to the include array multiline', () => {
29 | const tsContents = `{
30 | "compilerOptions": {
31 | "target": "es2015"
32 | },
33 | "include": [
34 | "src/**/*",
35 | "foo.d.ts"
36 | ]
37 | }`;
38 |
39 | const expected = `{
40 | "compilerOptions": {
41 | "target": "es2015"
42 | },
43 | "include": [
44 | "video.d.ts",
45 | "src/**/*",
46 | "foo.d.ts"
47 | ]
48 | }`;
49 |
50 | const actual = updateTSConfigFileContent(tsContents);
51 |
52 | assert.equal(actual, expected);
53 | });
54 | });
55 |
56 | describe('checkPackageJsonForNextVideo', () => {
57 | it('should return true if next-video is in devDependencies', async () => {
58 | assert.equal(await checkPackageJsonForNextVideo('./tests/factories/package.devDep.json'), true);
59 | });
60 |
61 | it('should return true if next-video is in dependencies', async () => {
62 | assert.equal(await checkPackageJsonForNextVideo('./tests/factories/package.dep.json'), true);
63 | });
64 |
65 | it('should return false if next-video is in neither', async () => {
66 | assert.equal(await checkPackageJsonForNextVideo('./tests/factories/package.none.json'), false);
67 | });
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/tests/cli/lib/next-config.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { after, describe, it } from 'node:test';
3 | import fs from 'node:fs/promises';
4 | import path from 'node:path';
5 |
6 | import updateNextConfigFile from '../../../src/cli/lib/next-config.js';
7 |
8 | function outputConfigName(configName: string) {
9 | if (configName.endsWith('.mjs')) {
10 | return 'next.config.mjs';
11 | }
12 |
13 | if (configName.endsWith('.ts')) {
14 | return 'next.config.ts';
15 | }
16 |
17 | // We have to return cjs files so we can import them async in a test.
18 | return 'next.config.js';
19 | }
20 |
21 | describe('updateNextConfig', () => {
22 | let tmpDirs: string[] = [];
23 |
24 | async function createTempDirWithConfig(configName: string): Promise {
25 | const dir = await fs.mkdtemp(path.join('tests', 'tmp-configs-'));
26 | tmpDirs.push(dir);
27 |
28 | const outputName = outputConfigName(configName);
29 | await fs.copyFile(path.join('tests', 'factories', configName), path.join(dir, outputName));
30 |
31 | return dir;
32 | }
33 |
34 | after(() => {
35 | tmpDirs.forEach(async (dir) => {
36 | await fs.rm(dir, { recursive: true, force: true });
37 | });
38 | });
39 |
40 | it('should add next-video to the next.config.js file', async () => {
41 | const dirPath = await createTempDirWithConfig('next.config.js');
42 | await updateNextConfigFile(dirPath);
43 |
44 | const updatedContents = await fs.readFile(path.join(dirPath, 'next.config.js'), 'utf-8');
45 |
46 | assert(updatedContents.includes('next-video'));
47 | });
48 |
49 | it('should add next-video to the next.config.mjs file', async () => {
50 | const dirPath = await createTempDirWithConfig('next.config.mjs');
51 | await updateNextConfigFile(dirPath);
52 |
53 | const updatedContents = await fs.readFile(path.join(dirPath, 'next.config.mjs'), 'utf-8');
54 | assert(updatedContents.includes('next-video'));
55 | });
56 |
57 | it('should add next-video to the next.config.ts file', async () => {
58 | const dirPath = await createTempDirWithConfig('next.config.ts');
59 | await updateNextConfigFile(dirPath);
60 |
61 | const updatedContents = await fs.readFile(path.join(dirPath, 'next.config.ts'), 'utf-8');
62 | assert(updatedContents.includes('next-video'));
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/tests/cli/sync.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import fs from 'node:fs/promises';
3 | import http from 'node:http';
4 | import path from 'node:path';
5 | import { describe, it, before, after, mock } from 'node:test';
6 |
7 | import Mux from '@mux/mux-node';
8 | import yargs from 'yargs';
9 | import log from '../../src/utils/logger.js';
10 |
11 | import { handler, builder } from '../../src/cli/sync.js';
12 | import { createAsset, updateAsset } from '../../src/assets.js';
13 |
14 | import * as fakeMux from '../utils/fake-mux.js';
15 |
16 | function findConsoleMessage(consoleSpy: any, regex: RegExp) {
17 | return consoleSpy.mock.calls.find(({ arguments: messages }) => {
18 | return messages.join('').match(regex);
19 | });
20 | }
21 |
22 | async function createFakeVideoFile(dir: string, filename: string = 'video.mp4'): Promise {
23 | const filePath = path.join(dir, filename);
24 | await fs.writeFile(filePath, 'fake video data');
25 |
26 | return filePath;
27 | }
28 |
29 | const silenceLog =
30 | (silence: boolean = true) =>
31 | (type: string, ...messages: string[]) => {
32 | if (silence) {
33 | return;
34 | }
35 |
36 | console.log(type, ...messages);
37 | };
38 |
39 | // I really hate this, but for whatever reason we're unable to mock the base log function
40 | // so we have to mock each individual method instead.
41 | function logSpies(ctx: any, silence: boolean = true) {
42 | return {
43 | infoSpy: ctx.mock.method(log, 'info', silenceLog(silence)),
44 | warningSpy: ctx.mock.method(log, 'warning', silenceLog(silence)),
45 | successSpy: ctx.mock.method(log, 'success', silenceLog(silence)),
46 | errorSpy: ctx.mock.method(log, 'error', silenceLog(silence)),
47 | addSpy: ctx.mock.method(log, 'add', silenceLog(silence)),
48 | spaceSpy: ctx.mock.method(log, 'space', silenceLog(silence)),
49 | };
50 | }
51 |
52 | describe('cli', () => {
53 | let server: http.Server;
54 | let tmpDirs: string[] = []; // Keep track of temporary directories so we can clean them up.
55 |
56 | async function createTempDir(): Promise {
57 | // Create a temporary directory and populate it with some files.
58 | const dir = await fs.mkdtemp('tmp-videos-');
59 | tmpDirs.push(dir);
60 | return dir;
61 | }
62 |
63 | before(() => {
64 | process.chdir('tests');
65 |
66 | process.env.MUX_TOKEN_ID = 'fake-token-id';
67 | process.env.MUX_TOKEN_SECRET = 'fake-token-secret';
68 |
69 | mock.method(Mux.prototype, 'post', fakeMux.post);
70 | mock.method(Mux.prototype, 'get', fakeMux.get);
71 |
72 | server = http.createServer((req, res) => {
73 | res.writeHead(200, { 'Content-Type': 'text/plain' });
74 | res.end('OK');
75 | });
76 |
77 | server.listen(3123, () => {
78 | // console.info('Dummy upload server running on http://localhost:3123');
79 | });
80 | });
81 |
82 | after(async () => {
83 | server.close();
84 |
85 | for (const dir of tmpDirs) {
86 | await fs.rm(dir, { recursive: true, force: true });
87 | }
88 |
89 | process.chdir('../');
90 | });
91 |
92 | describe('sync', () => {
93 | it('logs a warning and bails if the specified `dir` does not exist', async (t) => {
94 | const { warningSpy } = logSpies(t);
95 |
96 | const args = builder(yargs('')).parseSync();
97 |
98 | await handler(args);
99 |
100 | assert(findConsoleMessage(warningSpy, /Directory does not exist/i), 'Directory does not exist message not found');
101 | });
102 |
103 | it('processes new assets', async (t) => {
104 | const dir = await createTempDir();
105 |
106 | await createFakeVideoFile(dir);
107 |
108 | const { addSpy } = logSpies(t);
109 |
110 | const args = builder(yargs(`--dir ${dir}`)).parseSync();
111 |
112 | await handler(args);
113 |
114 | assert(findConsoleMessage(addSpy, /found 1/i), 'Found 1 message not found');
115 | });
116 |
117 | it('ignores dotfiles', async (t) => {
118 | const dir = await createTempDir();
119 |
120 | await fs.writeFile(path.join(dir, '.DS_Store'), 'whatever is in .DS_Store, this is it but fake.');
121 |
122 | const { addSpy } = logSpies(t);
123 |
124 | const args = builder(yargs(`--dir ${dir}`)).parseSync();
125 |
126 | await handler(args);
127 |
128 | assert(!findConsoleMessage(addSpy, /found 1/i), 'Found 1 message found');
129 | });
130 |
131 | it('ignores existing assets', async (t) => {
132 | await t.test('that are errored', async (t) => {
133 | const dir = await createTempDir();
134 |
135 | await createAsset(path.join(dir, 'video.mp4'), {});
136 | await updateAsset(path.join(dir, 'video.mp4'), { status: 'error' });
137 |
138 | const { addSpy, successSpy } = logSpies(t);
139 |
140 | const args = builder(yargs(`--dir ${dir}`)).parseSync();
141 |
142 | await handler(args);
143 |
144 | assert(!findConsoleMessage(addSpy, /found 1/i), 'Found 1 message found');
145 | assert(!findConsoleMessage(successSpy, /resumed.*1/i), 'Resumed 1 message not found');
146 | });
147 |
148 | await t.test('that are ready', async (t) => {
149 | const dir = await createTempDir();
150 |
151 | await createAsset(path.join(dir, 'video.mp4'), {});
152 | await updateAsset(path.join(dir, 'video.mp4'), { status: 'ready' });
153 |
154 | const { addSpy, successSpy } = logSpies(t);
155 |
156 | const args = builder(yargs(`--dir ${dir}`)).parseSync();
157 |
158 | await handler(args);
159 |
160 | assert(!findConsoleMessage(addSpy, /found 1/i), 'Found 1 message found');
161 | assert(!findConsoleMessage(successSpy, /resumed.*1/i), 'Resumed 1 message found');
162 | });
163 | });
164 |
165 | it('picks back up existing assets', async (t) => {
166 | await t.test('that are pending', async (t) => {
167 | const dir = await createTempDir();
168 |
169 | const filePath = await createFakeVideoFile(dir);
170 |
171 | await createAsset(filePath, {});
172 | await updateAsset(filePath, {
173 | status: 'pending',
174 | providerMetadata: {
175 | mux: { assetId: 'fake-asset-id' },
176 | },
177 | });
178 |
179 | const { addSpy, successSpy } = logSpies(t);
180 |
181 | const args = builder(yargs(`--dir ${dir}`)).parseSync();
182 |
183 | await handler(args);
184 |
185 | assert(!findConsoleMessage(addSpy, /1.*unprocessed/), '1 unprocessed message found');
186 | assert(findConsoleMessage(successSpy, /resumed.*1/i), 'Resumed message not found');
187 | });
188 |
189 | await t.test('that are uploading', async (t) => {
190 | const dir = await createTempDir();
191 |
192 | const filePath = await createFakeVideoFile(dir);
193 |
194 | await createAsset(filePath, {});
195 | await updateAsset(filePath, {
196 | status: 'uploading',
197 | providerMetadata: {
198 | mux: { assetId: 'fake-asset-id' },
199 | }
200 | });
201 |
202 | const { addSpy, successSpy } = logSpies(t);
203 |
204 | const args = builder(yargs(`--dir ${dir}`)).parseSync();
205 |
206 | await handler(args);
207 |
208 | assert(!findConsoleMessage(addSpy, /1.*unprocessed/));
209 | assert(findConsoleMessage(successSpy, /resumed.*1/i), 'Resumed message not found');
210 | });
211 |
212 | await t.test('that are processing', async (t) => {
213 | const dir = await createTempDir();
214 |
215 | const filePath = await createFakeVideoFile(dir);
216 |
217 | await createAsset(filePath, {});
218 | await updateAsset(filePath, {
219 | status: 'processing',
220 | providerMetadata: {
221 | mux: { assetId: 'fake-asset-id' },
222 | }
223 | });
224 |
225 | const { addSpy, successSpy } = logSpies(t);
226 |
227 | const args = builder(yargs(`--dir ${dir}`)).parseSync();
228 |
229 | await handler(args);
230 |
231 | assert(!findConsoleMessage(addSpy, /1.*unprocessed/));
232 | assert(findConsoleMessage(successSpy, /resumed.*1/i), 'Resumed message not found');
233 | });
234 | });
235 | });
236 | });
237 |
--------------------------------------------------------------------------------
/tests/components/alert.test.tsx:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import { setTimeout } from 'node:timers/promises';
4 | import { create } from 'react-test-renderer';
5 | import React from 'react';
6 | import { Alert } from '../../src/components/alert.js';
7 |
8 | test('renders an error alert', async () => {
9 | const wrapper = create( );
10 | await setTimeout(50);
11 | const fragment = wrapper.toJSON();
12 | assert.equal(fragment[1].type, 'div');
13 | assert.equal(fragment[1].props.className, 'next-video-alert next-video-alert-error');
14 | assert.equal(fragment[1].props.hidden, true);
15 | });
16 |
17 | test('renders a sourced alert', async () => {
18 | const wrapper = create( );
19 | await setTimeout(50);
20 | const fragment = wrapper.toJSON();
21 | assert.equal(fragment[1].type, 'div');
22 | assert.equal(fragment[1].props.className, 'next-video-alert next-video-alert-sourced');
23 | assert.equal(fragment[1].props.hidden, false);
24 | });
25 |
--------------------------------------------------------------------------------
/tests/components/utils.test.tsx:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import React from 'react';
4 | import { isReactComponent, getUrlExtension } from '../../src/components/utils.js';
5 |
6 | test('isReactComponent', () => {
7 | assert.ok(isReactComponent(() => null), 'function component');
8 | assert.ok(isReactComponent(class extends React.Component {}), 'class component');
9 | assert.ok(isReactComponent(React.memo(() => null)), 'memo');
10 | assert.ok(isReactComponent(React.forwardRef(() => null)), 'forwardRef');
11 | });
12 |
13 | test('getUrlExtension', () => {
14 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg'), 'jpg');
15 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg?foo=bar'), 'jpg');
16 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg#foo'), 'jpg');
17 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg?foo=bar#foo'), 'jpg');
18 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg?foo=bar&baz=qux'), 'jpg');
19 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg?foo=bar&baz=qux#foo'), 'jpg');
20 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg#foo?foo=bar&baz=qux'), 'jpg');
21 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg#foo?foo=bar&baz=qux#foo'), 'jpg');
22 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg?foo=bar&baz=qux#foo?foo=bar&baz=qux'), 'jpg');
23 | assert.strictEqual(getUrlExtension('https://example.com/image.jpg?foo=bar&baz=qux#foo?foo=bar&baz=qux#foo'), 'jpg');
24 | });
25 |
--------------------------------------------------------------------------------
/tests/components/video-loader.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test, mock } from 'node:test';
3 | import { defaultLoader, createVideoRequest } from '../../src/components/video-loader.js';
4 |
5 | test('createVideoRequest', async () => {
6 | const globalFetch = global.fetch;
7 |
8 | // @ts-ignore
9 | global.fetch = () => {
10 | return { ok: true, status: 200, json: async () => ({ status: 'ready' }) };
11 | };
12 |
13 | const loader = ({ config, src, width, height }: any) => {
14 | config.path = 'https://example.com/api/video';
15 | return defaultLoader({ config, src, width, height });
16 | };
17 |
18 | const props = { src: 'https://example.com/video.mp4' };
19 | const callback = (json) => {
20 | assert.equal(json.status, 'ready');
21 | };
22 |
23 | const request = createVideoRequest(loader, props, callback);
24 | await request(new AbortController().signal);
25 |
26 | global.fetch = globalFetch;
27 | });
28 |
--------------------------------------------------------------------------------
/tests/components/video.test.tsx:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import { setTimeout } from 'node:timers/promises';
4 | import { create } from 'react-test-renderer';
5 | import React from 'react';
6 | import asset from '../factories/BBB-720p-1min.mp4.json' assert { type: "json" };
7 | import Video from '../../src/components/video.js';
8 |
9 | test('renders a video container', async () => {
10 | const wrapper = create( );
11 | await setTimeout(50);
12 | assert.equal(wrapper.toJSON().type, 'div');
13 | assert.equal(wrapper.toJSON().props.className, 'next-video-container');
14 | });
15 |
16 | test('prepends a class to the video container', async () => {
17 | const wrapper = create( );
18 | await setTimeout(50);
19 | assert.equal(wrapper.toJSON().props.className, 'foo next-video-container');
20 | });
21 |
22 | test('renders native video without source', async () => {
23 | const wrapper = create( );
24 | await setTimeout(50);
25 | assert.equal(wrapper.toJSON().children[1].type, 'video');
26 | });
27 |
28 | test('renders mux-video without UI with imported source', async () => {
29 | await import('@mux/mux-video');
30 |
31 | const wrapper = create( );
32 | await setTimeout(400);
33 |
34 | assert.equal(wrapper.toJSON().children[1].type, 'mux-video');
35 | assert.equal(
36 | wrapper.root.findByType('mux-video').parent.props.playbackId,
37 | 'zNYmqdvJ61gt5uip02zPid01rYIPyyzVRVKQChgSgJlaY'
38 | );
39 | });
40 |
41 | test('renders media-controller and mux-video', async () => {
42 | await import('media-chrome');
43 | await import('@mux/mux-video');
44 |
45 | const wrapper = create( );
46 | await setTimeout(50);
47 |
48 | assert.equal(wrapper.toJSON().children[1].type, 'media-theme-sutro');
49 | assert.equal(wrapper.toJSON().children[1].children[1].type, 'mux-video');
50 | assert.equal(
51 | wrapper.root.findByType('mux-video').parent.props.playbackId,
52 | 'zNYmqdvJ61gt5uip02zPid01rYIPyyzVRVKQChgSgJlaY'
53 | );
54 | });
55 |
56 | test('renders mux-video with string source', async () => {
57 | await import('@mux/mux-video');
58 |
59 | process.env.NODE_ENV = 'development';
60 |
61 | let keepalive = globalThis.setTimeout(() => {}, 5_000);
62 |
63 | let resolve;
64 | const pollReady = new Promise((res) => {
65 | resolve = res;
66 | });
67 |
68 | let count = 0;
69 |
70 | const globalFetch = global.fetch;
71 |
72 | // @ts-ignore
73 | global.fetch = () => {
74 | return {
75 | ok: true,
76 | status: 200,
77 | json: async () => {
78 | count++;
79 |
80 | if (count < 2) {
81 | return {
82 | status: 'uploading',
83 | provider: 'mux',
84 | };
85 | }
86 |
87 | resolve();
88 |
89 | return {
90 | status: 'ready',
91 | provider: 'mux',
92 | providerMetadata: {
93 | mux: {
94 | playbackId: 'jxEf6XiJs6JY017pSzpv8Hd6tTbdAOecHTq4FiFAn564',
95 | },
96 | },
97 | sources: [{
98 | type: 'application/x-mpegURL',
99 | src: 'https://stream.mux.com/jxEf6XiJs6JY017pSzpv8Hd6tTbdAOecHTq4FiFAn564.m3u8'
100 | }]
101 | };
102 | }
103 | };
104 | };
105 |
106 | const wrapper = create( );
107 |
108 | await pollReady;
109 | await setTimeout(50);
110 |
111 | clearTimeout(keepalive);
112 |
113 | assert.equal(wrapper.toJSON().children[1].type, 'mux-video');
114 | assert.equal(
115 | wrapper.root.findByType('mux-video').parent.parent.props.playbackId,
116 | 'jxEf6XiJs6JY017pSzpv8Hd6tTbdAOecHTq4FiFAn564'
117 | );
118 |
119 | global.fetch = globalFetch;
120 | });
121 |
--------------------------------------------------------------------------------
/tests/config.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { describe, it, before, after } from 'node:test';
3 | import { getVideoConfig } from '../src/config.js';
4 |
5 | describe('config', () => {
6 | before(() => {
7 | process.chdir('tests');
8 | });
9 |
10 | after(() => {
11 | process.chdir('../');
12 | });
13 |
14 | it('getVideoConfig', async () => {
15 | // Test that the default config is returned if no next.config.js file is found.
16 | const videoConfig = await getVideoConfig();
17 | assert.equal(videoConfig.folder, 'videos');
18 | assert.equal(videoConfig.path, '/api/video');
19 | assert.equal(videoConfig.provider, 'mux');
20 | assert.deepStrictEqual(videoConfig.providerConfig, {});
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/tests/factories/BBB-720p-1min.mp4.json:
--------------------------------------------------------------------------------
1 | {"status":"ready","originalFilePath":"videos/BBB-720p-1min.mp4","provider":"mux","providerMetadata":{"mux":{"uploadId":"xzqTXHwWISCq01Uel01YzoxgQRpJpKCMs0101U1Nw00ImZLU","assetId":"h7PkfjvcQPJN9Dezf02FK4ZIXV014an35fq00xYDCN6WPs","playbackId":"zNYmqdvJ61gt5uip02zPid01rYIPyyzVRVKQChgSgJlaY"}},"createdAt":1703017365275,"updatedAt":1703017411140,"size":18734482,"sources":[{"src":"https://stream.mux.com/zNYmqdvJ61gt5uip02zPid01rYIPyyzVRVKQChgSgJlaY.m3u8","type":"application/x-mpegURL"}],"poster":"https://image.mux.com/zNYmqdvJ61gt5uip02zPid01rYIPyyzVRVKQChgSgJlaY/thumbnail.webp","blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAASCAYAAAA6yNxSAAAJcklEQVR4AQCBAH7/AE9kPv9OYz3/TmI7/01gOP9NXjb/TV00/05dMv9PXTH/UFwv/1FcLf9RWiv/T1co/0xTJP9ITyD/REsc/0BHGv88RBj/OUIY/zZAGf8yPhr/Lz0c/ys7Hv8nOR//Ijcg/x01If8ZMyP/FjIl/xQyKP8TMyv/EzQu/xM1MP8UNjL/AIEAfv8ATGI+/0xhPf9MYDz/S185/0tdN/9MXTX/TV00/09dM/9QXDL/UVww/1FaLf9PVyr/TFQm/0hPIv9ESx7/QEcc/zxEGv84QRr/NT8a/zI+HP8uPB3/Kjsf/yY5IP8hNiH/HTQj/xkyJP8WMif/FDIp/xMzLf8TNDD/FDYy/xQ2NP8AgQB+/wBIXj//SF4+/0hdPf9IXDv/SFs6/0pbOP9MXDf/Tlw3/1BdNv9RXDT/UVsy/09YL/9MVCv/SFAm/0RLIv8/Rx//O0Md/zhBHf80Px3/MT0e/y07H/8pOiH/JTci/yA1I/8cMyX/GDIn/xYxKf8UMiz/FDMw/xQ1M/8VNjb/FTc3/wCBAH7/AEJaQP9DWUD/Q1k//0RZPv9FWT3/R1o8/0pbPP9NXDz/T108/1FdOv9RXDj/T1k1/01VMf9IUCz/REwo/z9HJP87QyL/N0Ag/zM+IP8wPCH/LDoi/yg4I/8kNiX/HzQm/xsyJ/8YMSr/FjEs/xUyMP8VMzT/FTU4/xY3Ov8XODz/AIEAfv8APFRB/z1UQf8+VUD/P1VA/0JXQP9FWEH/SFpB/0xcQv9PXkL/UV5B/1JdP/9QWjv/TVY3/0lSMv9ETC3/P0cp/ztDJv83QCX/Mz0k/y87JP8rOSX/Jzcm/yM1J/8eMyj/GzEq/xgwLP8WMTD/FTIz/xY0OP8XNzz/GDk//xk6Qf8AgQB+/wA2UEL/N1BC/zlRQv87UkL/P1VD/0NXRf9IWkb/TF1H/1BfSP9SYEf/U19F/1JcQv9PWD3/SlM4/0VOMv9ASC7/O0Qq/zdAKP8zPSf/Lzsn/ys4KP8nNij/IzQp/x8yKv8bMSz/GDAv/xcxMv8XMzb/GDU7/xo4P/8bOkP/HDxF/wCBAH7/ADJMQv8zTUL/NU5D/zhRRP89VEb/QldI/0hbSv9NX0z/UWFN/1RiTf9VYkr/VF9H/1FbQv9NVTz/R1A2/0JKMf89RS3/OUEr/zU+Kv8xPCn/LTkp/yk3Kv8lNSr/ITMr/x0xLf8bMTD/GjIz/xo0OP8cNz3/HjtC/yA9Rf8hP0f/AIEAfv8AMEtB/zFMQv80TkP/OFFF/z1VR/9DWUr/SV1N/09iT/9UZFD/V2ZQ/1hlTv9XYkr/VF5E/1BYPv9LUzj/Rk0z/0FIL/89RCz/OUEr/zU/Kv8xPCr/LToq/yk4K/8lNiz/IjQt/yA0MP8fNTT/IDg4/yI7Pf8kPkL/JkFG/ydDSP8AgQB+/wAwTED/Mk1A/zVPQv86U0T/P1dH/0ZcS/9NYU7/U2ZQ/1lpUf9calH/XWlP/1xmSv9ZYkX/VV0//1BXOf9MUjP/R00v/0NKLf9ARyv/PEQr/zlCKv81QCr/MT0r/y07K/8qOi3/KDov/yc7M/8oPTj/KkE9/yxEQv8vR0b/MElI/wCBAH7/ADRPPf81UT7/OVNA/z5XQ/9EXEb/S2FJ/1JmTf9Za0//Xm5Q/2JvUP9jb03/YmxJ/2BoQ/9cYj3/WF03/1RYMv9QVC7/TVEs/0pPK/9HTSr/REsq/0BJKv88Rir/OEQr/zVDLP8zQy7/M0Qy/zNGNv81STv/OE1A/zpQRP87UUb/AIEAfv8AOlU6/zxXO/9AWT3/RV1A/0tiQ/9TaEf/Wm1K/2FxTP9mdU3/aXZM/2t1Sf9qckT/aG4//2VpOf9hZTT/XmEv/1teLP9ZWyv/V1oq/1VYKv9SVyr/T1Uq/0xTKv9IUSv/RU8r/0JPLf9CUDD/QlI1/0RVOf9HWD7/SVtC/0pdRP8AgQB+/wBDXTb/RV83/0hiOf9OZjz/VGo//1twQv9jdUX/aXlH/258SP9yfUb/c3xD/3N5P/9xdjn/b3I0/2xuL/9qayv/aWkp/2hoKf9nZyn/Zmcq/2RmKv9iZSv/XmMr/1tgK/9XXyz/VV4t/1ReMP9UYDP/VmM4/1hmPP9aaUD/XGtC/wCBAH7/AE1nMv9PaDP/U2s1/1hvN/9edDr/ZXk9/2x9QP9zgUH/eIRB/3uFP/98hDz/fIE4/3t+M/96ey7/eHgq/3h2J/94dib/eHYn/3l3KP95dyr/eHcr/3Z2LP90dSz/cHMs/21xLP9qcC3/aXAw/2lxM/9qdDf/bHY7/255Pv9vekD/AIEAfv8AWXEu/1pyL/9edTD/Y3gz/2l9Nf9wgTj/doY6/3yJO/+BjDr/hIw4/4aLNf+GiTD/hocs/4WEKP+FgiX/hoIj/4eDJP+KhSX/jIco/42IK/+NiS3/jIku/4qILv+Ghi7/g4Qu/4CCL/9+gjH/foMz/3+FN/+BiDr/goo+/4OLP/8AgQB+/wBjeir/ZXwr/2l+LP9tgi7/c4Yw/3mKMv+AjjT/hZE0/4qTM/+NkzH/j5Iu/4+RKf+QjyX/kI0i/5GMIP+TjSD/lo8h/5qTJP+dlij/oJks/6GbL/+hmzD/n5ox/5yYMf+YljH/lZQx/5OUMv+TlDT/k5Y4/5WYO/+Wmj7/l5w//wCBAH7/AG2DJv9uhCf/coYo/3aJKv98jSz/gpEu/4iVL/+Nly//kZkt/5SZK/+WmCj/l5ck/5iVIP+ZlR3/m5Uc/5+XHf+jmh//qJ8k/62jKP+wpy3/s6ox/7OrM/+xqjT/rqg0/6qmM/+npDP/paM0/6SkNv+kpTn/pqc8/6epPv+oqkD/AIEAfv8Ac4kk/3WKJf94jCb/fI8n/4KSKf+Ilir/jZkr/5KcK/+XnSn/mZ4n/5udI/+dnB//npoc/6CaGv+jmxn/p54b/6yiHv+yqCP/uK0p/7yyLv+/tTL/wLY1/762Nv+7tDb/uLI1/7WwNf+yrzb/sa83/7GwOv+ysjz/s7M//7S0QP8BgQB+/wB3jCP/eY0j/3yPJP+Akib/hZUn/4uZKP+QnCn/lZ4p/5mgJ/+coCT/np8h/6CeHf+hnRr/o50Y/6efGP+rohr/sace/7esI/++sin/w7cv/8a7M//HvDb/xbw3/8O6N/+/uDb/vLY2/7m1N/+4tTj/uLY6/7m3Pf+6uT//urpB/wvxXQ0WnKT2AAAAAElFTkSuQmCC"}
--------------------------------------------------------------------------------
/tests/factories/next.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('next').NextConfig}
3 | */
4 | const nextConfig = {
5 | /* config options here */
6 | };
7 |
8 | module.exports = nextConfig;
9 |
--------------------------------------------------------------------------------
/tests/factories/next.config.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('next').NextConfig}
3 | */
4 | const nextConfig = {
5 | /* config options here */
6 | };
7 |
8 | export default nextConfig;
9 |
--------------------------------------------------------------------------------
/tests/factories/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next'
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | }
6 |
7 | export default nextConfig
8 |
--------------------------------------------------------------------------------
/tests/factories/next.function.config.js:
--------------------------------------------------------------------------------
1 | module.exports = (phase, { defaultConfig }) => {
2 | /**
3 | * @type {import('next').NextConfig}
4 | */
5 | const nextConfig = {
6 | /* config options here */
7 | };
8 | return nextConfig;
9 | };
10 |
--------------------------------------------------------------------------------
/tests/factories/next.promise.config.js:
--------------------------------------------------------------------------------
1 | module.exports = async (phase, { defaultConfig }) => {
2 | /**
3 | * @type {import('next').NextConfig}
4 | */
5 | const nextConfig = {
6 | ...defaultConfig,
7 | };
8 | return nextConfig;
9 | };
10 |
--------------------------------------------------------------------------------
/tests/factories/package.dep.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mux/hypnodrones",
3 | "version": "0.1.0",
4 | "description": "A super awesome package.json for testing purposes",
5 | "exports": {},
6 | "scripts": {},
7 | "author": "Mux Lab ",
8 | "license": "MIT",
9 | "peerDependencies": {},
10 | "peerDependenciesMeta": {},
11 | "devDependencies": {
12 | "next-video": "*"
13 | },
14 | "dependencies": {}
15 | }
16 |
--------------------------------------------------------------------------------
/tests/factories/package.devDep.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mux/hypnodrones",
3 | "version": "0.1.0",
4 | "description": "A super awesome package.json for testing purposes",
5 | "exports": {},
6 | "scripts": {},
7 | "author": "Mux Lab ",
8 | "license": "MIT",
9 | "peerDependencies": {},
10 | "peerDependenciesMeta": {},
11 | "devDependencies": {},
12 | "dependencies": {
13 | "next-video": "*"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/tests/factories/package.none.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mux/hypnodrones",
3 | "version": "0.1.0",
4 | "description": "A super awesome package.json for testing purposes",
5 | "exports": {},
6 | "scripts": {},
7 | "author": "Mux Lab ",
8 | "license": "MIT",
9 | "peerDependencies": {},
10 | "peerDependenciesMeta": {},
11 | "devDependencies": {},
12 | "dependencies": {}
13 | }
14 |
--------------------------------------------------------------------------------
/tests/next.config.js:
--------------------------------------------------------------------------------
1 | // This file is used in the tests to mock the next.config.js file!!!
2 | import { withNextVideo } from 'next-video/process';
3 |
4 | /** @type {import('next').NextConfig} */
5 | const nextConfig = {};
6 |
7 | export default withNextVideo(nextConfig);
8 |
--------------------------------------------------------------------------------
/tests/providers/amazon-s3/transformer.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import { transform } from '../../../src/providers/amazon-s3/transformer.js';
4 | import type { Asset } from '../../../src/assets.js';
5 |
6 | test('transform', async () => {
7 | const asset: Asset = {
8 | status: 'ready',
9 | originalFilePath: '/videos/get-started.mp4',
10 | createdAt: 0,
11 | updatedAt: 0,
12 | provider: 'amazon-s3',
13 | providerMetadata: {
14 | ['amazon-s3']: {
15 | endpoint: 'https://amazon-s3-url.com',
16 | bucket: 'bucket',
17 | key: 'key',
18 | },
19 | },
20 | };
21 |
22 | const transformedAsset = transform(asset);
23 |
24 | assert.deepStrictEqual(transformedAsset, {
25 | ...asset,
26 | sources: [{ src: 'https://bucket.amazon-s3-url.com/key' }],
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/tests/providers/backblaze/transformer.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import { transform } from '../../../src/providers/backblaze/transformer.js';
4 | import type { Asset } from '../../../src/assets.js';
5 |
6 | test('transform', async () => {
7 | const asset: Asset = {
8 | status: 'ready',
9 | originalFilePath: '/videos/get-started.mp4',
10 | createdAt: 0,
11 | updatedAt: 0,
12 | provider: 'backblaze',
13 | providerMetadata: {
14 | ['backblaze']: {
15 | endpoint: 'https://backblaze-url.com',
16 | bucket: 'bucket',
17 | key: 'key',
18 | },
19 | },
20 | };
21 |
22 | const transformedAsset = transform(asset);
23 |
24 | assert.deepStrictEqual(transformedAsset, {
25 | ...asset,
26 | sources: [{ src: 'https://bucket.backblaze-url.com/key' }],
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/tests/providers/mux/transformer.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import { transform } from '../../../src/providers/mux/transformer.js';
4 | import type { Asset } from '../../../src/assets.js';
5 |
6 | test('transform', async () => {
7 | const asset: Asset = {
8 | status: 'ready',
9 | originalFilePath: '/videos/get-started.mp4',
10 | createdAt: 0,
11 | updatedAt: 0,
12 | provider: 'mux',
13 | providerMetadata: {
14 | mux: {
15 | playbackId: 'playbackId',
16 | },
17 | },
18 | };
19 |
20 | const transformedAsset = transform(asset, {
21 | customDomain: 'custom-mux.com',
22 | thumbnailTime: 20,
23 | });
24 |
25 | assert.deepStrictEqual(transformedAsset, {
26 | ...asset,
27 | sources: [{ src: 'https://stream.custom-mux.com/playbackId.m3u8', type: 'application/x-mpegURL' }],
28 | poster: 'https://image.custom-mux.com/playbackId/thumbnail.webp?time=20',
29 | thumbnailTime: 20,
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/tests/providers/vercel-blob/transformer.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import { transform } from '../../../src/providers/vercel-blob/transformer.js';
4 | import type { Asset } from '../../../src/assets.js';
5 |
6 | test('transform', async () => {
7 | const asset: Asset = {
8 | status: 'ready',
9 | originalFilePath: '/videos/get-started.mp4',
10 | createdAt: 0,
11 | updatedAt: 0,
12 | provider: 'vercel-blob',
13 | providerMetadata: {
14 | ['vercel-blob']: {
15 | url: 'https://vercel-blob-url.com/get-started.mp4',
16 | contentType: 'video/mp4',
17 | },
18 | },
19 | };
20 |
21 | const transformedAsset = transform(asset);
22 |
23 | assert.deepStrictEqual(transformedAsset, {
24 | ...asset,
25 | sources: [{ src: 'https://vercel-blob-url.com/get-started.mp4', type: 'video/mp4' }],
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/tests/utils.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { describe, it } from 'node:test';
3 | import { deepMerge } from '../src/utils/utils.js';
4 |
5 | describe('utils', () => {
6 | it('deepMerge', () => {
7 | const a = {
8 | foo: {
9 | bar: 'baz',
10 | qux: 'quux',
11 | },
12 | beep: 'boop',
13 | providerMetadata: {
14 | mux: {
15 | uploadId: '1',
16 | assetId: '2',
17 | }
18 | }
19 | };
20 |
21 | const b = {
22 | foo: {
23 | bar: 'baz2',
24 | },
25 | beep: 'boop2',
26 | providerMetadata: {
27 | mux: {
28 | assetId: '3',
29 | playbackId: '4',
30 | }
31 | }
32 | };
33 |
34 | const c = deepMerge(a, b);
35 |
36 | assert(c.foo.bar === 'baz2');
37 | assert(c.foo.qux === 'quux');
38 | assert(c.beep === 'boop2');
39 | assert(c.providerMetadata.mux.uploadId === '1');
40 | assert(c.providerMetadata.mux.assetId === '3');
41 | assert(c.providerMetadata.mux.playbackId === '4');
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/tests/utils/fake-mux.ts:
--------------------------------------------------------------------------------
1 | function generateRandomString() {
2 | return Math.random().toString(36).substring(2, 15);
3 | }
4 |
5 | export const get = (url: string, options: any = {}) => {
6 | const [_, _video, _v1, objectType, id] = url.split('/');
7 |
8 | if (objectType === 'uploads') {
9 | return {
10 | _thenUnwrap(transform) {
11 | return transform(this);
12 | },
13 | data: {
14 | id: id,
15 | asset_id: `fake-asset-id-${generateRandomString()}`,
16 | },
17 | };
18 | }
19 |
20 | if (objectType === 'assets') {
21 | return {
22 | _thenUnwrap(transform) {
23 | return transform(this);
24 | },
25 | data: {
26 | id: id,
27 | status: 'ready',
28 | playback_ids: [{ id: '4dcO6muLn7wz9pPTNrTboJxb74Z9XyWK' }],
29 | },
30 | };
31 | }
32 | };
33 |
34 | export const post = (url: string, options: any = {}) => {
35 | const [_, _video, _v1, objectType] = url.split('/');
36 |
37 | const fakeId = generateRandomString();
38 |
39 | if (objectType === 'uploads') {
40 | return {
41 | _thenUnwrap(transform) {
42 | return transform(this);
43 | },
44 | data: {
45 | id: `fake-upload-${fakeId}`,
46 | url: `http://localhost:3123/fake-upload-url-${fakeId}`,
47 | },
48 | };
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/tests/utils/provider.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { test } from 'node:test';
3 | import { setVideoConfig } from '../../src/config.js'
4 | import { createAssetKey } from '../../src/utils/provider.js';
5 |
6 | test('createAssetKey w/ defaultGenerateAssetKey and local asset', async () => {
7 | setVideoConfig({
8 | folder: 'videos',
9 | providerConfig: {
10 | 'vercel-blob': {},
11 | },
12 | });
13 |
14 | assert.equal(
15 | await createAssetKey('/videos/get-started.mp4', 'vercel-blob'),
16 | '/videos/get-started.mp4'
17 | );
18 |
19 | setVideoConfig({});
20 | });
21 |
22 | test('createAssetKey w/ defaultGenerateAssetKey and remote asset', async () => {
23 | setVideoConfig({
24 | folder: 'videos',
25 | providerConfig: {
26 | 'vercel-blob': {},
27 | },
28 | });
29 |
30 | assert.equal(
31 | await createAssetKey('https://storage.googleapis.com/muxdemofiles/mux.mp4', 'vercel-blob'),
32 | 'videos/mux.mp4'
33 | );
34 |
35 | setVideoConfig({});
36 | });
37 |
38 | test('createAssetKey w/ custom generateAssetKey and remote asset', async () => {
39 | setVideoConfig({
40 | folder: 'videos',
41 | providerConfig: {
42 | 'vercel-blob': {
43 | generateAssetKey: (filePathOrURL, folder) => {
44 | const url = new URL(filePathOrURL);
45 | return `${folder}/remote${url.pathname}`;
46 | },
47 | },
48 | },
49 | });
50 |
51 | assert.equal(
52 | await createAssetKey('https://storage.googleapis.com/muxdemofiles/mux.mp4', 'vercel-blob'),
53 | 'videos/remote/muxdemofiles/mux.mp4'
54 | );
55 |
56 | setVideoConfig({});
57 | });
58 |
--------------------------------------------------------------------------------
/tests/video-handler.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { describe, it } from 'node:test';
3 |
4 | import { videoHandler, callHandler } from '../src/video-handler.js';
5 |
6 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
7 |
8 | describe('videoHandler', () => {
9 | it('returns a curried function for the callback', async () => {
10 | const handler = videoHandler('test', async (event) => {
11 | return event;
12 | });
13 |
14 | assert((await handler('test')) === 'test');
15 | });
16 |
17 | it('adds the handler to be called via callHandler', async () => {
18 | const randomNumber = Math.floor(Math.random() * 1000);
19 | videoHandler(`test_${randomNumber}`, (event) => {
20 | return event;
21 | });
22 |
23 | const result = await callHandler(`test_${randomNumber}`, 'oh hai');
24 |
25 | assert(result === 'oh hai');
26 | });
27 | });
28 |
29 | describe('callHandler', () => {
30 | it('should call the handler for the given event', async () => {
31 | const randomNumber = Math.floor(Math.random() * 1000);
32 | videoHandler(`test_${randomNumber}`, (event) => {
33 | return event;
34 | });
35 |
36 | const result = await callHandler(`test_${randomNumber}`, 'oh hai');
37 |
38 | assert(result === 'oh hai');
39 | });
40 |
41 | it('should return undefined if no handler is registered', async () => {
42 | const result = await callHandler(`test_${Math.floor(Math.random() * 1000)}`, 'oh hai');
43 | assert(result === undefined);
44 | });
45 |
46 | it('should always return a promise even if the handler is not', async () => {
47 | const randomNumber = Math.floor(Math.random() * 1000);
48 | videoHandler(`test_${randomNumber}`, (event) => {
49 | return event;
50 | });
51 |
52 | const result = callHandler(`test_${randomNumber}`, 'oh hai');
53 | assert(result instanceof Promise);
54 | });
55 |
56 | it.skip('should timeout if the handler takes too long', async () => {
57 | const randomNumber = Math.floor(Math.random() * 1000);
58 | videoHandler(`test_${randomNumber}`, async (event) => {
59 | await sleep(20);
60 | return event;
61 | });
62 |
63 | assert.rejects(callHandler(`test_${randomNumber}`, 'oh hai', { timeout: 10 }));
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/tests/with-next-video.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { describe, it } from 'node:test';
3 |
4 | import { withNextVideo } from '../src/with-next-video.js';
5 | import { Asset } from '../src/assets.js';
6 | import { getVideoConfig } from '../src/config.js';
7 |
8 | describe('withNextVideo', () => {
9 | it('should handle nextConfig being a function', async () => {
10 | const nextConfig = (phase, { defaultConfig }) => {
11 | /**
12 | * @type {import('next').NextConfig}
13 | */
14 | const nextConfig = {
15 | ...defaultConfig,
16 | };
17 | return nextConfig;
18 | };
19 |
20 | const result = await withNextVideo(nextConfig);
21 |
22 | const configResult = await result('phase', { defaultConfig: {} });
23 |
24 | assert(typeof configResult.webpack === 'function');
25 | });
26 |
27 | it('should handle nextConfig being a promise', async () => {
28 | const nextConfig = async (phase, { defaultConfig }) => {
29 | /**
30 | * @type {import('next').NextConfig}
31 | */
32 | const nextConfig = {
33 | ...defaultConfig,
34 | };
35 | return nextConfig;
36 | };
37 |
38 | const result = await withNextVideo(nextConfig);
39 |
40 | const configResult = await result('phase', { defaultConfig: {} });
41 |
42 | assert(typeof configResult.webpack === 'function');
43 | });
44 |
45 | it('should handle nextConfig being an object', async () => {
46 | const nextConfig = {};
47 |
48 | const result = await withNextVideo(nextConfig);
49 |
50 | assert(typeof result.webpack === 'function');
51 | });
52 |
53 | it('should handle videoConfig being passed', async () => {
54 | const nextConfig = {};
55 | const fakeLoadAsset = function (path: string): Promise { return Promise.resolve(undefined) }
56 | const fakeSaveAsset = function (path: string, asset: Asset): Promise { return Promise.resolve() }
57 | const fakeUpdateAsset = function (path: string, asset: Asset): Promise { return Promise.resolve() }
58 |
59 | await withNextVideo(nextConfig, {
60 | path: '/api/video-files',
61 | folder: 'video-files',
62 | provider: 'vercel-blob',
63 | loadAsset: fakeLoadAsset,
64 | saveAsset: fakeSaveAsset,
65 | updateAsset: fakeUpdateAsset
66 | });
67 |
68 | const config = await getVideoConfig();
69 | assert.deepEqual(config, {
70 | path: '/api/video-files',
71 | folder: 'video-files',
72 | provider: 'vercel-blob',
73 | providerConfig: {},
74 | loadAsset: fakeLoadAsset,
75 | saveAsset: fakeSaveAsset,
76 | updateAsset: fakeUpdateAsset
77 | });
78 | });
79 |
80 | it('should change the webpack config', async () => {
81 | const nextConfig = {};
82 | const result = await withNextVideo(nextConfig);
83 | const config = {
84 | externals: [],
85 | experiments: {},
86 | module: {
87 | rules: [],
88 | },
89 | };
90 | const options = {
91 | defaultLoaders: true,
92 | };
93 | const webpackConfig = result.webpack(config, options);
94 |
95 | assert.equal(webpackConfig.module.rules.length, 2);
96 | assert.deepEqual(webpackConfig.infrastructureLogging, { level: 'error' });
97 | });
98 | });
99 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "es2020",
5 | "moduleResolution": "bundler",
6 | "jsx": "react-jsx",
7 | "strict": true,
8 | "declaration": true,
9 | "emitDeclarationOnly": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "preserveWatchOutput": true
13 | },
14 | "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"],
15 | "exclude": ["node_modules", "dist", "video-types"]
16 | }
17 |
--------------------------------------------------------------------------------
/video-types/global.d.ts:
--------------------------------------------------------------------------------
1 | // Escape hatch for video files in a custom folder with URL params.
2 | declare module '*&next-video' {
3 | const content: import('../dist/assets').Asset;
4 |
5 | export default content;
6 | }
7 |
8 | declare module '/videos/*' {
9 | const content: import('../dist/assets').Asset;
10 |
11 | export default content;
12 | }
13 |
14 | declare module '*.mp4' {
15 | const content: import('../dist/assets').Asset;
16 |
17 | export default content;
18 | }
19 |
20 | declare module '*.webm' {
21 | const content: import('../dist/assets').Asset;
22 |
23 | export default content;
24 | }
25 |
26 | declare module '*.mkv' {
27 | const content: import('../dist/assets').Asset;
28 |
29 | export default content;
30 | }
31 |
32 | declare module '*.ogg' {
33 | const content: import('../dist/assets').Asset;
34 |
35 | export default content;
36 | }
37 |
38 | declare module '*.ogv' {
39 | const content: import('../dist/assets').Asset;
40 |
41 | export default content;
42 | }
43 |
44 | declare module '*.wmv' {
45 | const content: import('../dist/assets').Asset;
46 |
47 | export default content;
48 | }
49 |
50 | declare module '*.avi' {
51 | const content: import('../dist/assets').Asset;
52 |
53 | export default content;
54 | }
55 |
56 | declare module '*.mov' {
57 | const content: import('../dist/assets').Asset;
58 |
59 | export default content;
60 | }
61 |
62 | declare module '*.flv' {
63 | const content: import('../dist/assets').Asset;
64 |
65 | export default content;
66 | }
67 |
68 | declare module '*.m4v' {
69 | const content: import('../dist/assets').Asset;
70 |
71 | export default content;
72 | }
73 |
74 | declare module '*.3gp' {
75 | const content: import('../dist/assets').Asset;
76 |
77 | export default content;
78 | }
79 |
--------------------------------------------------------------------------------