diff --git a/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts b/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts new file mode 100644 index 00000000000..879985d8772 --- /dev/null +++ b/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: TemplateCustomizeCreateMuxVideoUploadByFileMutation +// ==================================================== + +export interface TemplateCustomizeCreateMuxVideoUploadByFileMutation_createMuxVideoUploadByFile { + __typename: "MuxVideo"; + uploadUrl: string | null; + id: string; +} + +export interface TemplateCustomizeCreateMuxVideoUploadByFileMutation { + createMuxVideoUploadByFile: TemplateCustomizeCreateMuxVideoUploadByFileMutation_createMuxVideoUploadByFile; +} + +export interface TemplateCustomizeCreateMuxVideoUploadByFileMutationVariables { + name: string; +} diff --git a/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts b/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts new file mode 100644 index 00000000000..0f392894465 --- /dev/null +++ b/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: TemplateCustomizeGetMyMuxVideoQuery +// ==================================================== + +export interface TemplateCustomizeGetMyMuxVideoQuery_getMyMuxVideo { + __typename: "MuxVideo"; + id: string; + assetId: string | null; + playbackId: string | null; + readyToStream: boolean; +} + +export interface TemplateCustomizeGetMyMuxVideoQuery { + getMyMuxVideo: TemplateCustomizeGetMyMuxVideoQuery_getMyMuxVideo; +} + +export interface TemplateCustomizeGetMyMuxVideoQueryVariables { + id: string; +} diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts new file mode 100644 index 00000000000..c7b781c6ef0 --- /dev/null +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts @@ -0,0 +1,2 @@ +export { useVideoUpload } from './useVideoUpload' +export type { VideoUploadStatus } from './useVideoUpload' diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx new file mode 100644 index 00000000000..dbdf2637014 --- /dev/null +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx @@ -0,0 +1,387 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing' +import { act, renderHook, waitFor } from '@testing-library/react' +import { UpChunk } from '@mux/upchunk' +import { + useVideoUpload, + CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION, + GET_MY_MUX_VIDEO_QUERY +} from './useVideoUpload' + +jest.mock('@mux/upchunk', () => ({ + UpChunk: { + createUpload: jest.fn() + } +})) + +jest.mock('react-dropzone', () => ({ + useDropzone: jest.fn(() => ({ + getRootProps: jest.fn(), + getInputProps: jest.fn(), + open: jest.fn() + })) +})) + +const createMuxVideoUploadByFileMock: MockedResponse = { + request: { + query: CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION, + variables: { name: 'video.mp4' } + }, + result: { + data: { + createMuxVideoUploadByFile: { + __typename: 'MuxVideo', + uploadUrl: 'https://mux.com/upload', + id: 'videoId' + } + } + } +} + +const getMyMuxVideoMock: MockedResponse = { + request: { + query: GET_MY_MUX_VIDEO_QUERY, + variables: { id: 'videoId' } + }, + result: { + data: { + getMyMuxVideo: { + __typename: 'MuxVideo', + id: 'videoId', + assetId: 'assetId', + playbackId: 'playbackId', + readyToStream: true + } + } + } +} + +const getMyMuxVideoProcessingMock: MockedResponse = { + request: { + query: GET_MY_MUX_VIDEO_QUERY, + variables: { id: 'videoId' } + }, + result: { + data: { + getMyMuxVideo: { + __typename: 'MuxVideo', + id: 'videoId', + assetId: 'assetId', + playbackId: 'playbackId', + readyToStream: false + } + } + } +} + +describe('useVideoUpload', () => { + const file = new File([''], 'video.mp4', { type: 'video/mp4' }) + + it('should handle successful upload and polling', async () => { + const onUploadComplete = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload({ onUploadComplete }), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + expect(result.current.status).toBe('uploading') + expect(UpChunk.createUpload).toHaveBeenCalledWith({ + endpoint: 'https://mux.com/upload', + file, + chunkSize: 5120 + }) + + // Simulate progress + const progressCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'progress' + )[1] + act(() => { + progressCallback({ detail: 50 }) + }) + expect(result.current.progress).toBe(50) + + // Simulate success + const successCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'success' + )[1] + await act(async () => { + successCallback() + }) + + await waitFor(() => expect(result.current.status).toBe('completed')) + expect(onUploadComplete).toHaveBeenCalledWith('videoId') + }) + + it('should set videoId when upload starts', async () => { + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload(), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + expect(result.current.videoId).toBe('videoId') + }) + + it('should handle upload error', async () => { + const onUploadError = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload({ onUploadError }), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + const errorCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'error' + )[1] + + act(() => { + errorCallback() + }) + + expect(result.current.status).toBe('error') + expect(result.current.error).toBe('Upload failed') + expect(onUploadError).toHaveBeenCalledWith('Upload failed') + }) + + it('should handle file too large error', async () => { + const onUploadError = jest.fn() + const largeFile = new File([''], 'large.mp4', { type: 'video/mp4' }) + Object.defineProperty(largeFile, 'size', { value: 2 * 1024 * 1024 * 1024 }) // 2GB + + const { result } = renderHook(() => useVideoUpload({ onUploadError }), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(largeFile) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error).toBe('File is too large. Max size is 1GB.') + expect(onUploadError).toHaveBeenCalledWith( + 'File is too large. Max size is 1GB.' + ) + }) + + it('should cancel upload', async () => { + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload(), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + expect(result.current.status).toBe('uploading') + + act(() => { + result.current.cancelUpload() + }) + + expect(mockUpload.abort).toHaveBeenCalled() + expect(result.current.status).toBe('idle') + expect(result.current.progress).toBe(0) + expect(result.current.error).toBeUndefined() + expect(result.current.videoId).toBeUndefined() + }) + + it('should poll with exponential backoff until ready', async () => { + const onUploadComplete = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook( + () => useVideoUpload({ onUploadComplete, initialPollInterval: 100 }), + { + wrapper: ({ children }) => ( + + {children} + + ) + } + ) + + await act(async () => { + await result.current.handleUpload(file) + }) + + // Simulate success to start polling + const successCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'success' + )[1] + + await act(async () => { + successCallback() + }) + + // Wait for the immediate poll to be called and for status to change to processing + await waitFor(() => expect(result.current.status).toBe('processing')) + + // Wait for the second poll to complete and status to change to completed + // We use a shorter timeout since we reduced the interval to 100ms for faster testing. + // Note: The backoff timing here (100ms, 150ms, etc.) is reduced for test performance + // and does not match the real-world production intervals (2s, 3s, etc.). + await waitFor(() => expect(result.current.status).toBe('completed'), { + timeout: 2000 + }) + expect(onUploadComplete).toHaveBeenCalledWith('videoId') + }) + + it('should handle polling error', async () => { + const onUploadError = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const pollingErrorMock: MockedResponse = { + request: { + query: GET_MY_MUX_VIDEO_QUERY, + variables: { id: 'videoId' } + }, + error: new Error('Polling failed') + } + + const { result } = renderHook( + () => useVideoUpload({ onUploadError, initialPollInterval: 100 }), + { + wrapper: ({ children }) => ( + + {children} + + ) + } + ) + + await act(async () => { + await result.current.handleUpload(file) + }) + + // Simulate success to start polling + const successCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'success' + )[1] + + await act(async () => { + successCallback() + }) + + // Wait for all 3 retries to exhaust (3 retries * 100ms delay = ~300ms + buffer) + // Note: The backoff timing here is reduced for test performance and does not match + // the real-world production intervals. + await waitFor(() => expect(result.current.status).toBe('error'), { + timeout: 2000 + }) + expect(result.current.error).toBe('Failed to check video status') + expect(onUploadError).toHaveBeenCalledWith('Failed to check video status') + }) + + it('should cleanup on unmount', async () => { + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result, unmount } = renderHook(() => useVideoUpload(), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + unmount() + + expect(mockUpload.abort).toHaveBeenCalled() + }) +}) diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx new file mode 100644 index 00000000000..dc0feb54dbe --- /dev/null +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx @@ -0,0 +1,236 @@ +import { gql, useLazyQuery, useMutation } from '@apollo/client' +import { UpChunk } from '@mux/upchunk' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDropzone } from 'react-dropzone' + +export const CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION = gql` + mutation TemplateCustomizeCreateMuxVideoUploadByFileMutation($name: String!) { + createMuxVideoUploadByFile(name: $name) { + uploadUrl + id + } + } +` + +export const GET_MY_MUX_VIDEO_QUERY = gql` + query TemplateCustomizeGetMyMuxVideoQuery($id: ID!) { + getMyMuxVideo(id: $id) { + id + assetId + playbackId + readyToStream + } + } +` + +const INITIAL_POLL_INTERVAL = 2000 // 2 seconds +const MAX_POLL_INTERVAL = 30000 // 30 seconds +const MAX_RETRIES = 3 +const MAX_VIDEO_SIZE = 1073741824 // 1GB + +export type VideoUploadStatus = + | 'idle' + | 'uploading' + | 'processing' + | 'completed' + | 'error' + +interface UseVideoUploadOptions { + onUploadComplete?: (videoId: string) => void + onUploadError?: (error: string) => void + /** @default 2000 */ + initialPollInterval?: number + /** @default 3 */ + maxRetries?: number +} + +export function useVideoUpload({ + onUploadComplete, + onUploadError, + initialPollInterval = INITIAL_POLL_INTERVAL, + maxRetries = MAX_RETRIES +}: UseVideoUploadOptions = {}) { + const [status, setStatus] = useState('idle') + const [progress, setProgress] = useState(0) + const [error, setError] = useState() + const [videoId, setVideoId] = useState() + + const uploadInstanceRef = useRef<{ abort: () => void } | null>(null) + const pollingTimeoutRef = useRef(null) + const retryCountRef = useRef(0) + + const [createMuxVideoUploadByFile] = useMutation( + CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION + ) + + const [getMyMuxVideo] = useLazyQuery(GET_MY_MUX_VIDEO_QUERY, { + fetchPolicy: 'network-only' + }) + + const clearPolling = useCallback(() => { + if (pollingTimeoutRef.current != null) { + clearTimeout(pollingTimeoutRef.current) + pollingTimeoutRef.current = null + } + retryCountRef.current = 0 + }, []) + + const cancelUpload = useCallback(() => { + uploadInstanceRef.current?.abort() + uploadInstanceRef.current = null + clearPolling() + setStatus('idle') + setProgress(0) + setError(undefined) + setVideoId(undefined) + }, [clearPolling]) + + const startPolling = useCallback( + (videoId: string) => { + setStatus('processing') + + const poll = async (delay: number) => { + try { + const result = await getMyMuxVideo({ + variables: { id: videoId } + }) + + if (result.error != null) { + throw result.error + } + + if (result.data?.getMyMuxVideo?.readyToStream === true) { + clearPolling() + setStatus('completed') + onUploadComplete?.(videoId) + return + } + + // Reset retries on successful query (even if not ready) + retryCountRef.current = 0 + + // Schedule next poll with exponential backoff + const nextDelay = Math.min(delay * 1.5, MAX_POLL_INTERVAL) + pollingTimeoutRef.current = setTimeout(() => { + void poll(nextDelay) + }, delay) + } catch (err) { + // Retry on recoverable errors (e.g., network issues or 500s) + if (retryCountRef.current < maxRetries) { + retryCountRef.current++ + pollingTimeoutRef.current = setTimeout(() => { + void poll(delay) + }, delay) + return + } + + clearPolling() + setStatus('error') + setError('Failed to check video status') + onUploadError?.('Failed to check video status') + } + } + + void poll(initialPollInterval) + }, + [ + clearPolling, + getMyMuxVideo, + onUploadComplete, + onUploadError, + initialPollInterval, + maxRetries + ] + ) + + const handleUpload = useCallback( + async (file: File) => { + if (file.size > MAX_VIDEO_SIZE) { + const message = 'File is too large. Max size is 1GB.' + setStatus('error') + setError(message) + onUploadError?.(message) + return + } + + setStatus('uploading') + setProgress(0) + setError(undefined) + + try { + const { data } = await createMuxVideoUploadByFile({ + variables: { name: file.name } + }) + + const uploadUrl = data?.createMuxVideoUploadByFile?.uploadUrl + const videoId = data?.createMuxVideoUploadByFile?.id + setVideoId(videoId) + + if (uploadUrl == null || videoId == null) { + throw new Error('Failed to create upload URL') + } + + const upload = UpChunk.createUpload({ + endpoint: uploadUrl, + file, + chunkSize: 5120 // 5MB + }) + + uploadInstanceRef.current = upload + + upload.on('progress', (progress) => { + setProgress(progress.detail) + }) + + upload.on('success', () => { + uploadInstanceRef.current = null + startPolling(videoId) + }) + + upload.on('error', () => { + setStatus('error') + setError('Upload failed') + onUploadError?.('Upload failed') + }) + } catch (err) { + setStatus('error') + const message = err instanceof Error ? err.message : 'Upload failed' + setError(message) + onUploadError?.(message) + } + }, + [createMuxVideoUploadByFile, startPolling, onUploadError] + ) + + const { getRootProps, getInputProps, open } = useDropzone({ + onDropAccepted: (files) => { + void handleUpload(files[0]) + }, + noDrag: true, + multiple: false, + accept: { 'video/*': [] }, + disabled: status !== 'idle' && status !== 'error' && status !== 'completed' + }) + + // Cleanup on unmount + useEffect(() => { + return () => { + uploadInstanceRef.current?.abort() + if (pollingTimeoutRef.current != null) { + clearTimeout(pollingTimeoutRef.current) + } + } + }, []) + + return { + handleUpload, + cancelUpload, + status, + progress, + error, + videoId, + open, + getInputProps, + getRootProps + } +}