Skip to content

Commit

Permalink
feat: update schema, add provider key + transform (#113)
Browse files Browse the repository at this point in the history
* feat: update schema, add provider key + transform

* fix: use `sources` directly for Vercel Blob

* fix: cache await call

* fix: cache object lookup, add specifics types

* Revert "fix: use `sources` directly for Vercel Blob"

This reverts commit 2b1922e.

* fix: add TransformedAsset type
  • Loading branch information
luwes committed Nov 29, 2023
1 parent 49734d9 commit 540e705
Show file tree
Hide file tree
Showing 19 changed files with 385 additions and 211 deletions.
86 changes: 58 additions & 28 deletions src/assets.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,72 @@
import { env } from 'node:process';
import { readFile, writeFile } from 'node:fs/promises';
import { relative } from 'node:path';
import { cwd } from 'node:process';
import { stat, readFile, writeFile } from 'node:fs/promises';
import { getVideoConfig } from './config.js';
import { deepMerge } from './utils.js';

export interface Asset {
status?: 'sourced' | 'pending' | 'uploading' | 'processing' | 'ready' | 'error';
error?: any;
originalFilePath?: string;
status: 'sourced' | 'pending' | 'uploading' | 'processing' | 'ready' | 'error';
originalFilePath: string;
provider: string;
providerSpecific?: {
[provider: string]: { [key: string]: any }
};
blurDataURL?: string;
size?: number;
error?: any;
createdAt: number;
updatedAt: number;

// Here for backwards compatibility with older assets.
externalIds?: {
[key: string]: string; // { uploadId, playbackId, assetId }
};
blurDataURL?: string;
createdAt?: number;
updatedAt?: number;
}

export interface TransformedAsset extends Asset {
poster?: string;
sources?: AssetSource[];
}

export interface AssetSource {
src: string;
type?: string;
}

export async function getAsset(filePath: string): Promise<Asset | undefined> {
const assetPath = getAssetConfigPath(filePath);
const assetPath = await getAssetConfigPath(filePath);
const file = await readFile(assetPath);
const asset = JSON.parse(file.toString());

return asset;
}

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

let originalFilePath = filePath;
if (!isRemote(filePath)) {
originalFilePath = relative(cwd(), filePath);
}

const newAssetDetails: Asset = {
status: 'pending', // overwritable
...assetDetails,
status: 'pending',
originalFilePath: filePath,
externalIds: {},
originalFilePath,
provider: videoConfig.provider,
providerSpecific: {},
createdAt: Date.now(),
updatedAt: Date.now(),
};

if (!isRemote(filePath)) {
try {
newAssetDetails.size = (await stat(filePath))?.size;
} catch {
// Ignore error.
}
}

try {
await writeFile(assetPath, JSON.stringify(newAssetDetails), { flag: 'wx' });
} catch (err: any) {
Expand All @@ -47,29 +80,26 @@ export async function createAsset(filePath: string, assetDetails?: Asset): Promi
return newAssetDetails;
}

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

export async function updateAsset(filePath: string, assetDetails: Partial<Asset>) {
const assetPath = await getAssetConfigPath(filePath);
const currentAsset = await getAsset(filePath);

const newAssetDetails = {
...currentAsset,
...assetDetails,
externalIds: {
...currentAsset?.externalIds,
...assetDetails.externalIds,
},
if (!currentAsset) {
throw new Error(`Asset not found: ${filePath}`);
}

const newAssetDetails = deepMerge(currentAsset, assetDetails, {
updatedAt: Date.now(),
};
}) as Asset;

await writeFile(assetPath, JSON.stringify(newAssetDetails));

return newAssetDetails;
}

function getAssetConfigPath(filePath: string) {
export async function getAssetConfigPath(filePath: string) {
if (isRemote(filePath)) {
const VIDEOS_DIR = JSON.parse(env['__NEXT_VIDEO_OPTS'] ?? '{}').folder;
const VIDEOS_DIR = (await getVideoConfig()).folder;
if (!VIDEOS_DIR) throw new Error('Missing video `folder` config.');

// Add the asset directory and make remote url a safe file path.
Expand Down
6 changes: 0 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env node
import process from 'node:process';
import path from 'node:path';
import nextEnv from '@next/env';
import log from './logger.js';
import yargs from 'yargs/yargs';
Expand All @@ -11,8 +10,3 @@ import * as sync from './cli/sync.js';
nextEnv.loadEnvConfig(process.cwd(), undefined, log);

yargs(process.argv.slice(2)).command(init).command(sync).demandCommand().help().argv;

// Import the app's next.config.js file so the env variables
// __NEXT_VIDEO_OPTS set in with-next-video can be used.
import(path.resolve('next.config.js'))
.catch(() => log.error('Failed to load next.config.js'));
34 changes: 13 additions & 21 deletions src/cli/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import chalk from 'chalk';
import chokidar from 'chokidar';
import { Argv, Arguments } from 'yargs';

import { env, cwd } from 'node:process';
import { stat, readdir } from 'node:fs/promises';
import { cwd } from 'node:process';
import { readdir } from 'node:fs/promises';
import path from 'node:path';

import log from '../logger.js';
import { callHandler } from '../process.js';
import { createAsset, getAsset } from '../assets.js';
import { getVideoConfig } from '../config.js';
import { getNextVideoVersion } from './lib/json-configs.js';

export const command = 'sync';
Expand Down Expand Up @@ -38,23 +39,17 @@ function watcher(dir: string) {
persistent: true,
});

watcher.on('add', async (filePath, stats) => {
const relativePath = path.relative(cwd(), filePath);
const newAsset = await createAsset(relativePath, {
size: stats?.size,
});
watcher.on('add', async (filePath) => {
const newAsset = await createAsset(filePath);

if (newAsset) {
log.add(`New file found: ${filePath}`);
return callHandler('local.video.added', newAsset, getCallHandlerConfig());
const videoConfig = await getVideoConfig();
return callHandler('local.video.added', newAsset, videoConfig);
}
});
}

function getCallHandlerConfig() {
return JSON.parse(env['__NEXT_VIDEO_OPTS'] ?? '{}');
}

export async function handler(argv: Arguments) {
const directoryPath = path.join(cwd(), argv.dir as string);

Expand All @@ -76,16 +71,12 @@ export async function handler(argv: Arguments) {
const newFileProcessor = async (file: string) => {
log.info(log.label('Processing file:'), file);

const absolutePath = path.join(directoryPath, file);
const relativePath = path.relative(cwd(), absolutePath);
const stats = await stat(absolutePath);

const newAsset = await createAsset(relativePath, {
size: stats.size,
});
const filePath = path.join(directoryPath, file);
const newAsset = await createAsset(filePath);

if (newAsset) {
return callHandler('local.video.added', newAsset, getCallHandlerConfig());
const videoConfig = await getVideoConfig();
return callHandler('local.video.added', newAsset, videoConfig);
}
};

Expand All @@ -99,7 +90,8 @@ export async function handler(argv: Arguments) {
// it back through the local video handler.
const assetStatus = existingAsset?.status;
if (assetStatus && ['sourced', 'pending', 'uploading', 'processing'].includes(assetStatus)) {
return callHandler('local.video.added', existingAsset, getCallHandlerConfig());
const videoConfig = await getVideoConfig();
return callHandler('local.video.added', existingAsset, videoConfig);
}
};

Expand Down
4 changes: 2 additions & 2 deletions src/components/default-player.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { forwardRef } from 'react';
import MuxPlayer from '@mux/mux-player-react';
import { getPosterURLFromPlaybackId } from './utils.js';
import { getPlaybackId, getPosterURLFromPlaybackId } from '../providers/mux/transformer.js';

import type { MuxPlayerProps, MuxPlayerRefAttributes } from '@mux/mux-player-react';
import type { PlayerProps } from './types.js';
Expand All @@ -24,7 +24,7 @@ export const DefaultPlayer = forwardRef<DefaultPlayerRefAttributes | null, Defau

const props: MuxPlayerProps = rest;
const imgStyleProps: React.CSSProperties = {};
const playbackId = asset?.externalIds?.playbackId;
const playbackId = asset ? getPlaybackId(asset) : undefined;

let isCustomPoster = true;
let srcSet: string | undefined;
Expand Down
58 changes: 5 additions & 53 deletions src/components/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useRef, useCallback } from 'react';
import type { PosterProps } from './types';

export const config = JSON.parse(
process.env.NEXT_PUBLIC_DEV_VIDEO_OPTS
Expand All @@ -11,11 +10,15 @@ const MUX_VIDEO_DOMAIN = 'mux.com';
const DEFAULT_POLLING_INTERVAL = 5000;
const FILES_FOLDER = `${config.folder ?? 'videos'}/`;

export const toSymlinkPath = (path?: string) => {
export function toSymlinkPath(path?: string) {
if (!path?.startsWith(FILES_FOLDER)) return path;
return path?.replace(FILES_FOLDER, `_next-video/`);
}

export function camelCase(name: string) {
return name.replace(/[-_]([a-z])/g, ($0, $1) => $1.toUpperCase());
}

// Note: doesn't get updated when the callback function changes
export function usePolling(
callback: (abortSignal: AbortSignal) => any,
Expand Down Expand Up @@ -67,54 +70,3 @@ export function useInterval(callback: () => any, delay: number | null) {
}
}, [delay]);
}

export const getPosterURLFromPlaybackId = (
playbackId?: string,
{ token, thumbnailTime, width, domain = MUX_VIDEO_DOMAIN }: PosterProps = {}
) => {
// NOTE: thumbnailTime is not supported when using a signedURL/token. Remove under these cases. (CJP)
const time = token == null ? thumbnailTime : undefined;

const { aud } = parseJwt(token);

if (token && aud !== 't') {
return;
}

return `https://image.${domain}/${playbackId}/thumbnail.webp${toQuery({
token,
time,
width,
})}`;
};

function toQuery(obj: Record<string, any>) {
const params = toParams(obj).toString();
return params ? '?' + params : '';
}

function toParams(obj: Record<string, any>) {
const params: Record<string, any> = {};
for (const key in obj) {
if (obj[key] != null) params[key] = obj[key];
}
return new URLSearchParams(params);
}

function parseJwt(token: string | undefined) {
const base64Url = (token ?? '').split('.')[1];

// exit early on invalid value
if (!base64Url) return {};

const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
return JSON.parse(jsonPayload);
}
28 changes: 15 additions & 13 deletions src/components/video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import React, { forwardRef, useState } from 'react';
import { DefaultPlayer } from './default-player.js';
import { Alert } from './alert.js';
import { createVideoRequest, defaultLoader } from './video-loader.js';
import { getPosterURLFromPlaybackId, toSymlinkPath, usePolling } from './utils.js';
import { config, camelCase, toSymlinkPath, usePolling } from './utils.js';
import * as transformers from '../providers/transformers.js';

import type { DefaultPlayerRefAttributes, DefaultPlayerProps } from './default-player.js';
import type { Asset } from '../assets.js';
Expand Down Expand Up @@ -111,20 +112,12 @@ export function getVideoProps(allProps: VideoProps, state: { asset?: Asset }) {
if (asset.status === 'ready') {
props.blurDataURL ??= asset.blurDataURL;

// Mux provider
const playbackId = asset.externalIds?.playbackId;

if (playbackId) {
// src can't be overridden by the user.
props.src = `https://stream.mux.com/${playbackId}.m3u8`;
props.poster ??= getPosterURLFromPlaybackId(playbackId, props);
}
// Vercel Blob provider
else if (asset.externalIds?.url) {
const transformedAsset = transform(asset);
if (transformedAsset) {
// src can't be overridden by the user.
props.src = asset.externalIds?.url;
props.src = transformedAsset.sources?.[0]?.src;
props.poster ??= transformedAsset.poster;
}

} else {
props.src = toSymlinkPath(asset.originalFilePath);
}
Expand All @@ -133,6 +126,15 @@ export function getVideoProps(allProps: VideoProps, state: { asset?: Asset }) {
return props;
}

function transform(asset: Asset) {
const provider = asset.provider ?? config.provider;
for (let [key, transformer] of Object.entries(transformers)) {
if (key === camelCase(provider)) {
return transformer.transform(asset);
}
}
}

export default NextVideo;

export type {
Expand Down
20 changes: 20 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { env } from 'node:process';
import path from 'node:path';

/**
* Video configurations
*/
Expand All @@ -20,3 +23,20 @@ export const videoConfigDefault: VideoConfigComplete = {
path: '/api/video',
provider: 'mux',
}

/**
* The video config is set in `next.config.js` and passed to the `withNextVideo` function.
* The video config is then stored as an environment variable __NEXT_VIDEO_OPTS.
*/
export async function getVideoConfig(): Promise<VideoConfigComplete> {
if (!env['__NEXT_VIDEO_OPTS']) {
// Import the app's next.config.js file so the env variable
// __NEXT_VIDEO_OPTS set in with-next-video.ts can be used.
try {
await import(path.resolve('next.config.js'))
} catch {
console.error('Failed to load next.config.js');
}
}
return JSON.parse(env['__NEXT_VIDEO_OPTS'] ?? '{}');
}
Loading

0 comments on commit 540e705

Please sign in to comment.