diff --git a/src/assets.ts b/src/assets.ts index eb4b609..4c3b34d 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -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 { - 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 { - const assetPath = getAssetConfigPath(filePath); +export async function createAsset(filePath: string, assetDetails?: Partial) { + 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) { @@ -47,29 +80,26 @@ export async function createAsset(filePath: string, assetDetails?: Asset): Promi return newAssetDetails; } -export async function updateAsset(filePath: string, assetDetails: Asset): Promise { - const assetPath = getAssetConfigPath(filePath); - +export async function updateAsset(filePath: string, assetDetails: Partial) { + 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. diff --git a/src/cli.ts b/src/cli.ts index 88b03ff..138684f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -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')); diff --git a/src/cli/sync.ts b/src/cli/sync.ts index dc2088d..e764441 100644 --- a/src/cli/sync.ts +++ b/src/cli/sync.ts @@ -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'; @@ -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); @@ -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); } }; @@ -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); } }; diff --git a/src/components/default-player.tsx b/src/components/default-player.tsx index b4396fb..e934b8e 100644 --- a/src/components/default-player.tsx +++ b/src/components/default-player.tsx @@ -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'; @@ -24,7 +24,7 @@ export const DefaultPlayer = forwardRef { +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, @@ -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) { - const params = toParams(obj).toString(); - return params ? '?' + params : ''; -} - -function toParams(obj: Record) { - const params: Record = {}; - 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); -} diff --git a/src/components/video.tsx b/src/components/video.tsx index 82fe804..321f482 100644 --- a/src/components/video.tsx +++ b/src/components/video.tsx @@ -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'; @@ -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); } @@ -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 { diff --git a/src/config.ts b/src/config.ts index 52ae199..05cdebd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,6 @@ +import { env } from 'node:process'; +import path from 'node:path'; + /** * Video configurations */ @@ -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 { + 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'] ?? '{}'); +} diff --git a/src/providers/mux.ts b/src/providers/mux/provider.ts similarity index 82% rename from src/providers/mux.ts rename to src/providers/mux/provider.ts index c125b73..f190502 100644 --- a/src/providers/mux.ts +++ b/src/providers/mux/provider.ts @@ -10,24 +10,28 @@ import Mux from '@mux/mux-node'; import { fetch as uFetch } from 'undici'; import sharp from 'sharp'; -import { updateAsset, Asset } from '../assets.js'; -import log from '../logger.js'; +import { updateAsset, Asset } from '../../assets.js'; +import log from '../../logger.js'; +import { sleep } from '../../utils.js'; + +export type MuxSpecifics = { + uploadId?: string; + assetId?: string; + playbackId?: string; +} +// 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. 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)); + mux ??= new Mux(); } async function pollForAssetReady(filePath: string, asset: Asset) { - if (!asset.externalIds?.assetId) { + const providerSpecifics: MuxSpecifics | undefined = asset.providerSpecific?.mux; + + if (!providerSpecifics?.assetId) { log.error('No assetId provided for asset.'); console.error(asset); return; @@ -35,17 +39,18 @@ async function pollForAssetReady(filePath: string, asset: Asset) { initMux(); - const assetId = asset.externalIds?.assetId; - + const assetId = providerSpecifics?.assetId; const muxAsset = await mux.video.assets.retrieve(assetId); const playbackId = muxAsset.playback_ids?.[0].id!; let updatedAsset: Asset = asset; - if (asset.externalIds?.playbackId !== playbackId) { + if (providerSpecifics?.playbackId !== playbackId) { // We can go ahead and update it here so we have the playback ID, even before the Asset is ready. updatedAsset = await updateAsset(filePath, { - externalIds: { - playbackId, + providerSpecific: { + mux: { + playbackId, + } }, }); } @@ -73,10 +78,12 @@ async function pollForAssetReady(filePath: string, asset: Asset) { return updateAsset(filePath, { status: 'ready', - externalIds: { - playbackId, - }, blurDataURL, + providerSpecific: { + mux: { + playbackId, + } + }, }); // TODO: do we want to do something like `callHandlers('video.asset.ready', asset)` here? It'd be faking the webhook. @@ -88,7 +95,9 @@ async function pollForAssetReady(filePath: string, asset: Asset) { } async function pollForUploadAsset(filePath: string, asset: Asset) { - if (!asset.externalIds?.uploadId) { + const providerSpecifics: MuxSpecifics | undefined = asset.providerSpecific?.mux; + + if (!providerSpecifics?.uploadId) { log.error('No uploadId provided for asset.'); console.error(asset); return; @@ -96,8 +105,7 @@ async function pollForUploadAsset(filePath: string, asset: Asset) { initMux(); - const uploadId = asset.externalIds?.uploadId; - + const uploadId = providerSpecifics?.uploadId; const muxUpload = await mux.video.uploads.retrieve(uploadId); if (muxUpload.asset_id) { @@ -106,8 +114,10 @@ async function pollForUploadAsset(filePath: string, asset: Asset) { const processingAsset = await updateAsset(filePath, { status: 'processing', - externalIds: { - assetId: muxUpload.asset_id, + providerSpecific: { + mux: { + assetId: muxUpload.asset_id, + } }, }); @@ -164,8 +174,10 @@ export async function uploadLocalFile(asset: Asset) { await updateAsset(src, { status: 'uploading', - externalIds: { - uploadId: upload.id as string, // more typecasting while we use the beta mux sdk + providerSpecific: { + mux: { + uploadId: upload.id as string, // more typecasting while we use the beta mux sdk + } }, }); @@ -233,8 +245,10 @@ export async function uploadRequestedFile(asset: Asset) { const processingAsset = await updateAsset(src, { status: 'processing', - externalIds: { - assetId: assetObj.id!, + providerSpecific: { + mux: { + assetId: assetObj.id!, + } }, }); @@ -245,7 +259,8 @@ export async function createThumbHash(imgUrl: string) { const response = await uFetch(imgUrl); const buffer = await response.arrayBuffer(); - const { data, info } = await sharp(buffer).raw().ensureAlpha().toBuffer({ resolveWithObject: true }); + const { data, info } = await sharp(buffer) + .raw().ensureAlpha().toBuffer({ resolveWithObject: true }); // thumbhash is ESM only so dynamically import it. const { rgbaToThumbHash, thumbHashToDataURL } = await import('thumbhash'); diff --git a/src/providers/mux/transformer.ts b/src/providers/mux/transformer.ts new file mode 100644 index 0000000..d9a6a2c --- /dev/null +++ b/src/providers/mux/transformer.ts @@ -0,0 +1,92 @@ +import type { Asset, TransformedAsset } from '../../assets.js'; + +type Props = { + customDomain?: string; + thumbnailTime?: number; + startTime?: number; + tokens?: { thumbnail?: string } +}; + +type PosterProps = { + customDomain?: string; + thumbnailTime?: number; + token?: string; + width?: number; +} + +const MUX_VIDEO_DOMAIN = 'mux.com'; + +export function transform(asset: Asset, props?: Props): TransformedAsset { + const playbackId = getPlaybackId(asset); + if (!playbackId) return asset; + + return { + ...asset, + + sources: [ + { src: `https://stream.mux.com/${playbackId}.m3u8`, type: 'application/x-mpegURL' } + ], + + poster: getPosterURLFromPlaybackId(playbackId, { + customDomain: props?.customDomain, + thumbnailTime: props?.thumbnailTime ?? props?.startTime, + token: props?.tokens?.thumbnail, + }) + }; +} + +export function getPlaybackId(asset: Asset): string | undefined { + // Fallback to asset.externalIds for backwards compatibility with older assets. + const providerDetails = asset.providerSpecific?.mux ?? asset.externalIds; + return providerDetails?.playbackId; +} + +export const getPosterURLFromPlaybackId = ( + playbackId?: string, + { token, thumbnailTime, width, customDomain = 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.${customDomain}/${playbackId}/thumbnail.webp${toQuery({ + token, + time, + width, + })}`; +}; + +function toQuery(obj: Record) { + const params = toParams(obj).toString(); + return params ? '?' + params : ''; +} + +function toParams(obj: Record) { + const params: Record = {}; + 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); +} diff --git a/src/providers/providers.ts b/src/providers/providers.ts index 9a771d3..e8f9876 100644 --- a/src/providers/providers.ts +++ b/src/providers/providers.ts @@ -1,2 +1,2 @@ -export * as mux from './mux.js'; -export * as vercelBlob from './vercel-blob.js'; +export * as mux from './mux/provider.js'; +export * as vercelBlob from './vercel-blob/provider.js'; diff --git a/src/providers/transformers.ts b/src/providers/transformers.ts new file mode 100644 index 0000000..719c45f --- /dev/null +++ b/src/providers/transformers.ts @@ -0,0 +1,2 @@ +export * as mux from './mux/transformer.js'; +export * as vercelBlob from './vercel-blob/transformer.js'; diff --git a/src/providers/vercel-blob.ts b/src/providers/vercel-blob/provider.ts similarity index 84% rename from src/providers/vercel-blob.ts rename to src/providers/vercel-blob/provider.ts index 8b226f7..f3c7587 100644 --- a/src/providers/vercel-blob.ts +++ b/src/providers/vercel-blob/provider.ts @@ -1,17 +1,21 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fetch as uFetch } from 'undici'; -import { put, head } from '@vercel/blob'; +import { put } from '@vercel/blob'; import chalk from 'chalk'; -import { updateAsset, Asset } from '../assets.js'; -import log from '../logger.js'; - +import { updateAsset, Asset } from '../../assets.js'; +import log from '../../logger.js'; export const config = { runtime: 'edge', }; +export type VercelBlobSpecifics = { + url?: string; + contentType?: string; +} + export async function uploadLocalFile(asset: Asset) { if (!asset.originalFilePath) { log.error('No filePath provided for asset.'); @@ -62,8 +66,11 @@ export async function uploadLocalFile(asset: Asset) { return updateAsset(src, { status: 'ready', - externalIds: { - url: blob.url, + providerSpecific: { + 'vercel-blob': { + url: blob.url, + contentType: blob.contentType, + } as VercelBlobSpecifics }, }); } @@ -105,8 +112,11 @@ export async function uploadRequestedFile(asset: Asset) { return updateAsset(src, { status: 'ready', - externalIds: { - url: blob.url, + providerSpecific: { + 'vercel-blob': { + url: blob.url, + contentType: blob.contentType, + } as VercelBlobSpecifics }, }); } diff --git a/src/providers/vercel-blob/transformer.ts b/src/providers/vercel-blob/transformer.ts new file mode 100644 index 0000000..5c18ade --- /dev/null +++ b/src/providers/vercel-blob/transformer.ts @@ -0,0 +1,20 @@ +import type { Asset, TransformedAsset, AssetSource } from '../../assets.js'; + +export function transform(asset: Asset): TransformedAsset { + // Fallback to asset.externalIds for backwards compatibility with older assets. + const providerDetails = asset.providerSpecific?.['vercel-blob'] ?? asset.externalIds; + if (!providerDetails) return asset; + + const source: AssetSource = { + src: providerDetails.url + }; + + if (providerDetails.contentType) { + source.type = providerDetails.contentType; + } + + return { + ...asset, + sources: [source], + }; +} diff --git a/src/request-handler.ts b/src/request-handler.ts index e8f2789..41c8a9f 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { env } from 'node:process'; import { callHandler } from './process.js'; import { createAsset, getAsset } from './assets.js'; +import { getVideoConfig } from './config.js'; // App Router export async function GET(request: Request) { @@ -45,7 +45,8 @@ async function handleRequest(url?: string | null) { asset = await createAsset(url); if (asset) { - await callHandler('request.video.added', asset, getCallHandlerConfig()); + const videoConfig = await getVideoConfig(); + await callHandler('request.video.added', asset, videoConfig); } return { status: 200, data: asset }; @@ -53,7 +54,3 @@ async function handleRequest(url?: string | null) { return { status: 200, data: asset }; } - -function getCallHandlerConfig() { - return JSON.parse(env['__NEXT_VIDEO_OPTS'] ?? '{}'); -} diff --git a/src/utils.ts b/src/utils.ts index ea8dab7..e4b61d7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,33 @@ +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} export function camelCase(name: string) { return name.replace(/[-_]([a-z])/g, ($0, $1) => $1.toUpperCase()); } + +/** +* Performs a deep merge of objects and returns a new object. +* Does not modify objects (immutable) and merges arrays via concatenation. +*/ +export function deepMerge(...objects: any[]) { + const result: any = {}; + for (const obj of objects) { + for (const key in obj) { + const existing = result[key]; + const val = obj[key]; + if (Array.isArray(val) && Array.isArray(existing)) { + result[key] = existing.concat(...val); + } else if (isObject(val) && isObject(existing)) { + result[key] = deepMerge(existing, val); + } else { + result[key] = val; + } + } + } + return result; +} + +export function isObject(item: any) { + return (item && typeof item === 'object' && !Array.isArray(item)); +} diff --git a/src/webpack-loader.ts b/src/webpack-loader.ts index b3daddb..402fc37 100644 --- a/src/webpack-loader.ts +++ b/src/webpack-loader.ts @@ -1,53 +1,23 @@ import path from 'node:path'; -import { readFile, writeFile } from 'node:fs/promises'; -import { env } from 'node:process'; +import { readFile } from 'node:fs/promises'; +import { createAsset, getAssetConfigPath } from './assets.js'; // https://webpack.js.org/api/loaders#raw-loader export const raw = true; export default async function loader(this: any, source: Buffer) { - const assetPath = path.resolve(getAssetConfigPath(this.resourcePath)); + const assetPath = path.resolve(await getAssetConfigPath(this.resourcePath)); this.addDependency(assetPath); let asset; - try { asset = await readFile(assetPath, 'utf-8'); } catch { - - const originalFilePath = isRemote(this.resourcePath) - ? this.resourcePath - : path.relative(process.cwd(), this.resourcePath); - - asset = JSON.stringify({ - status: 'sourced', - originalFilePath - }); - - await writeFile(assetPath, asset, { flag: 'wx' }); + asset = JSON.stringify(await createAsset(this.resourcePath, { + status: 'sourced' + })); } return `export default ${asset}`; } - -function getAssetConfigPath(filePath: string) { - if (isRemote(filePath)) { - const VIDEOS_DIR = JSON.parse(env['__NEXT_VIDEO_OPTS'] ?? '{}').folder; - if (!VIDEOS_DIR) throw new Error('Missing video `folder` config.'); - - // 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, '_'); -} diff --git a/src/with-next-video.ts b/src/with-next-video.ts index 166e6f8..5b641cc 100644 --- a/src/with-next-video.ts +++ b/src/with-next-video.ts @@ -16,17 +16,17 @@ export async function withNextVideo(nextConfig: any, videoConfig?: VideoConfig) } const videoConfigComplete = Object.assign({}, videoConfigDefault, videoConfig); - const { path, folder } = videoConfigComplete; + const { path, folder, provider } = videoConfigComplete; // Don't use `process.env` here because Next.js replaces public env vars during build. - env['NEXT_PUBLIC_VIDEO_OPTS'] = JSON.stringify({ path }); + env['NEXT_PUBLIC_VIDEO_OPTS'] = JSON.stringify({ path, provider }); env['__NEXT_VIDEO_OPTS'] = JSON.stringify(videoConfigComplete); // We should probably switch to using `phase` here, just a bit concerned about backwards compatibility. if (process.argv[2] === 'dev') { // Don't use `process.env` here because Next.js replaces public env vars during build. - env['NEXT_PUBLIC_DEV_VIDEO_OPTS'] = JSON.stringify({ path, folder }); + env['NEXT_PUBLIC_DEV_VIDEO_OPTS'] = JSON.stringify({ path, folder, provider }); const VIDEOS_PATH = join(process.cwd(), folder) const TMP_PUBLIC_VIDEOS_PATH = join(process.cwd(), 'public', `_next-video`); diff --git a/tests/cli/sync.test.ts b/tests/cli/sync.test.ts index 009b784..3886f96 100644 --- a/tests/cli/sync.test.ts +++ b/tests/cli/sync.test.ts @@ -169,7 +169,9 @@ describe('cli', () => { await createAsset(filePath, {}); await updateAsset(filePath, { status: 'pending', - externalIds: { assetId: 'fake-asset-id' }, + providerSpecific: { + mux: { assetId: 'fake-asset-id' }, + }, }); const { addSpy, successSpy } = logSpies(t); @@ -190,7 +192,9 @@ describe('cli', () => { await createAsset(filePath, {}); await updateAsset(filePath, { status: 'uploading', - externalIds: { assetId: 'fake-asset-id' }, + providerSpecific: { + mux: { assetId: 'fake-asset-id' }, + } }); const { addSpy, successSpy } = logSpies(t); @@ -211,7 +215,9 @@ describe('cli', () => { await createAsset(filePath, {}); await updateAsset(filePath, { status: 'processing', - externalIds: { assetId: 'fake-asset-id' }, + providerSpecific: { + mux: { assetId: 'fake-asset-id' }, + } }); const { addSpy, successSpy } = logSpies(t); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..3c876c6 --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { deepMerge } from '../src/utils.js'; + +describe('utils', () => { + it('deepMerge', () => { + const a = { + foo: { + bar: 'baz', + qux: 'quux', + }, + beep: 'boop', + providerSpecific: { + mux: { + uploadId: '1', + assetId: '2', + } + } + }; + + const b = { + foo: { + bar: 'baz2', + }, + beep: 'boop2', + providerSpecific: { + mux: { + assetId: '3', + playbackId: '4', + } + } + }; + + const c = deepMerge(a, b); + + assert(c.foo.bar === 'baz2'); + assert(c.foo.qux === 'quux'); + assert(c.beep === 'boop2'); + assert(c.providerSpecific.mux.uploadId === '1'); + assert(c.providerSpecific.mux.assetId === '3'); + assert(c.providerSpecific.mux.playbackId === '4'); + }); +});