Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: update schema, add provider key + transform #113

Merged
merged 6 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I want to keep pushing on the idea of transforming the asset before it reaches <Video>. import awesomeVideo could include functions for getting dynamic URLs etc, not just metadata. Then I think the next-video UI layer becomes fully optional and you could just do <video src="${awesomeVideo.src}">. But I know the crux of that is remote URLs, so a bigger conversation.

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