Skip to content

Commit

Permalink
feat: add remote url support as string
Browse files Browse the repository at this point in the history
  • Loading branch information
luwes committed Sep 21, 2023
1 parent 4b5034e commit 35e722e
Show file tree
Hide file tree
Showing 11 changed files with 533 additions and 23 deletions.
341 changes: 338 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
"require": "./dist/cjs/main.js",
"default": "./dist/main.js"
},
"./video-types/*": "./video-types/*.d.ts"
"./request-handler": {
"import": "./dist/request-handler.js",
"require": "./dist/cjs/request-handler.js",
"default": "./dist/request-handler.js"
},
"./video-types/*": "./video-types/*.d.ts",
"./dist/*": "./dist/*"
},
"scripts": {
"clean": "rm -rf dist",
Expand Down Expand Up @@ -57,6 +63,7 @@
"@types/react": "^18.2.16",
"@types/yargs": "^17.0.24",
"glob": "^10.3.3",
"next": "^13.5.2",
"react": "^18.2.0",
"tsx": "^3.12.7",
"typescript": "^5.1.6"
Expand Down
25 changes: 20 additions & 5 deletions src/assets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile, writeFile } from 'node:fs/promises';
import log from './logger.js';
import { VIDEOS_DIR } from './constants.js';

export interface Asset {
status?: 'pending' | 'uploading' | 'processing' | 'ready' | 'error';
Expand All @@ -14,10 +15,6 @@ export interface Asset {
updatedAt?: number;
}

function getAssetConfigPath(filePath: string) {
return `${filePath}.json`;
}

export async function getAsset(filePath: string): Promise<Asset | undefined> {
const assetPath = getAssetConfigPath(filePath);
const file = await readFile(assetPath);
Expand All @@ -26,7 +23,7 @@ export async function getAsset(filePath: string): Promise<Asset | undefined> {
return asset;
}

export async function createAsset(filePath: string, assetDetails: Asset): Promise<Asset | undefined> {
export async function createAsset(filePath: string, assetDetails?: Asset): Promise<Asset | undefined> {
const assetPath = getAssetConfigPath(filePath);

const newAssetDetails: Asset = {
Expand Down Expand Up @@ -71,3 +68,21 @@ export async function updateAsset(filePath: string, assetDetails: Asset): Promis

return newAssetDetails;
}

function getAssetConfigPath(filePath: string) {
if (isRemote(filePath)) {
// Add the asset directory and make remote url a safe file path.
return `${VIDEOS_DIR}/${toSafePath(filePath)}.json`;
}
return `${filePath}.json`
}

function isRemote(filePath: string) {
return /^https?:\/\//.test(filePath);
}

function toSafePath(str: string) {
return str
.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '')
.replace(/[^a-zA-Z0-9._-]+/g, '_');
}
4 changes: 4 additions & 0 deletions src/cli/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export async function handler(argv: Arguments) {
const assetPath = path.join(parsedPath.dir, parsedPath.name);
const existingAsset = await getAsset(assetPath);

// Ignore remote videos.
const originalFilePath = existingAsset?.originalFilePath;
if (originalFilePath && /^https?:\/\//.test(originalFilePath)) return;

// If the existing asset is 'pending', 'uploading', or 'processing', run
// it back through the local video handler.
const assetStatus = existingAsset?.status;
Expand Down
3 changes: 2 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'node:path';

export const VIDEOS_PATH = path.join(process.cwd(), '/videos');
export const VIDEOS_DIR = 'videos';
export const VIDEOS_PATH = path.join(process.cwd(), VIDEOS_DIR);
export const PACKAGE_NAME = '@mux/next-video';
4 changes: 2 additions & 2 deletions src/handlers/local-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function pollForAssetReady(filePath: string, asset: Asset) {
export async function pollForAssetReady(filePath: string, asset: Asset) {
if (!asset.externalIds?.assetId) {
log.error('No assetId provided for asset.');
console.error(asset);
Expand Down Expand Up @@ -197,7 +197,7 @@ export default async function uploadLocalFile(asset: Asset) {
return pollForUploadAsset(src, processingAsset);
}

async function createThumbHash(imgUrl: string) {
export async function createThumbHash(imgUrl: string) {
const response = await uFetch(imgUrl);
const buffer = await response.arrayBuffer();

Expand Down
58 changes: 58 additions & 0 deletions src/handlers/remote-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import chalk from 'chalk';
import Mux from '@mux/mux-node';
import { fetch as uFetch } from 'undici';
import { updateAsset, Asset } from '../assets.js';
import log from '../logger.js';
import { pollForAssetReady } from './local-upload.js';

let mux: Mux;

// We don't want to blow things up immediately if Mux isn't configured, but we also don't want to
// need to initialize it every time in situations like polling. So we'll initialize it lazily but cache
// the instance.
function initMux() {
mux = new Mux();
}

function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export default async function handleRemoteVideoAdded(asset: Asset) {
if (!asset.originalFilePath) {
log.error('No URL provided for asset.');
console.error(asset);
return;
}

initMux();

if (asset.status === 'ready') {
return;
} else if (asset.status === 'processing') {
log.info(log.label('Asset is already processing. Polling for completion:'), asset.originalFilePath);
return pollForAssetReady(asset.originalFilePath, asset);
}

const src = asset.originalFilePath;

const assetObj = await mux.video.assets.create({
// @ts-ignore
input: [{
url: asset.originalFilePath
}],
playback_policy: ['public']
});

log.info(log.label('Asset is processing:'), src);
log.space(chalk.gray('>'), log.label('Mux Asset ID:'), assetObj.id);

const processingAsset = await updateAsset(src, {
status: 'processing',
externalIds: {
assetId: assetObj.id,
},
});

return pollForAssetReady(src, processingAsset);
}
6 changes: 4 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import videoHandler, { callHandler } from './video-handler.js';
import localUploadHandler from './handlers/local-upload.js';
import remoteRequestHandler from './handlers/remote-request.js';
import log from './logger.js';
import withNextVideo from './with-next-video.js';

try {
// Don't love this little race condition... we gotta figure that one out.
// Basically we need to make sure all the handlers are registered before we start watching for files.
videoHandler('local.video.added', localUploadHandler);
videoHandler('remote.video.added', remoteRequestHandler);

} catch (err) {
// We'd much prefer to log an error here than crash since it can put
// the main Next process in a weird state.
log.error('An exception occurred within next-video. You may need to restart your dev server.');
console.error(err);
}

import withNextVideo from './with-next-video.js';

export { videoHandler, withNextVideo, callHandler };
60 changes: 51 additions & 9 deletions src/next-video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ declare module 'react' {

interface NextVideoProps extends Omit<MuxPlayerProps, 'src'> {
src: string | Asset;
provider?: string;
width?: number;
height?: number;
controls?: boolean;
Expand All @@ -28,7 +29,7 @@ interface NextVideoProps extends Omit<MuxPlayerProps, 'src'> {
}

const DEV_MODE = process.env.NODE_ENV === 'development';
const FILES_FOLDER = 'videos/';
const FILES_FOLDER = /^videos\//;

const toSymlinkPath = (path?: string) => {
return path?.replace(FILES_FOLDER, `_${FILES_FOLDER}`);
Expand All @@ -41,21 +42,39 @@ export default function NextVideo(props: NextVideoProps) {
height,
poster,
blurDataURL,
provider = 'mux',
sizes = '100vw',
controls = true,
...rest
} = props;

const playerProps: MuxPlayerProps = rest;
let status: string | undefined;
let srcset: string | undefined;

if (typeof src === 'string') {
playerProps.src = toSymlinkPath(src);
let [asset, setAsset] = useState(src);

if (typeof asset === 'string') {

} else if (typeof src === 'object') {
status = src.status;
if (typeof window !== 'undefined') {

let playbackId = src.externalIds?.playbackId;
const requestUrl = new URL('/api/video', window.location.href);
requestUrl.searchParams.set('url', asset);
requestUrl.searchParams.set('provider', provider);

// try for 1 minute every second.
poll(async () => {
const response = await fetch(requestUrl);
const json = await response.json();
setAsset(json);
return json.status === 'ready';
}, 60000, 1000);
}

} else if (typeof asset === 'object') {
status = asset.status;

let playbackId = asset.externalIds?.playbackId;

if (status === 'ready' && playbackId) {
playerProps.playbackId = playbackId;
Expand All @@ -71,10 +90,10 @@ export default function NextVideo(props: NextVideoProps) {
`${poster} 1920w`;
}

blurDataURL = blurDataURL ?? src.blurDataURL;
blurDataURL = blurDataURL ?? asset.blurDataURL;

} else {
playerProps.src = toSymlinkPath(src.originalFilePath);
playerProps.src = toSymlinkPath(asset.originalFilePath);
}
}

Expand Down Expand Up @@ -107,7 +126,7 @@ export default function NextVideo(props: NextVideoProps) {
`
}</style>
<MuxPlayer
data-next-video={status}
data-next-video={status ?? ''}
poster=""
style={{
'--controls': controls === false ? 'none' : undefined,
Expand Down Expand Up @@ -273,3 +292,26 @@ function parseJwt(token: string | undefined) {
);
return JSON.parse(jsonPayload);
}

function poll(fn: Function, timeout: number, interval: number) {
const endTime = Number(new Date()) + (timeout || 2000);
interval = interval || 100;

const checkCondition = async (resolve: (value: unknown) => void, reject: (reason: Error) => void) => {
// If the condition is met, we're done!
const result = await fn();
if (result) {
resolve(result);
}
// If the condition isn't met but the timeout hasn't elapsed, go again
else if (Number(new Date()) < endTime) {
setTimeout(checkCondition, interval, resolve, reject);
}
// Didn't match and too much time, reject!
else {
reject(new Error('timed out for ' + fn + ': ' + arguments));
}
};

return new Promise(checkCondition);
}
36 changes: 36 additions & 0 deletions src/request-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from 'next';

import { callHandler } from './main.js';
import { createAsset, getAsset } from './assets.js';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {

const url = String(req.query.url);
if (!url) {
res.status(400).json({ error: 'url parameter is required' });
return;
}

const remoteRegex = /^https?:\/\//;
const isRemote = remoteRegex.test(url);

if (!isRemote) {
// todo: handle local files via string src
res.status(400).json({ error: 'local files should be imported as a module' });
return;
}

let asset;
try {
asset = await getAsset(url);
} catch {
// todo: does this require auth?
asset = await createAsset(url);
await callHandler('remote.video.added', asset);

res.status(200).json(asset);
return;
}

res.status(200).json(asset);
}
10 changes: 10 additions & 0 deletions src/with-next-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ export default async function withNextVideo(nextConfig: any) {
);
}

if (Array.isArray(config.externals)) {
config.externals.unshift({
sharp: 'commonjs sharp'
});
} else {
config.externals = Object.assign({}, {
sharp: 'commonjs sharp'
}, config.externals);
}

config.module.rules.push({
test: /\.(mp4|webm|mkv|ogg|ogv|wmv|avi|mov|flv|m4v|3gp)$/,
use: [
Expand Down

0 comments on commit 35e722e

Please sign in to comment.