diff --git a/src/components/input/ToolMultipleVideoInput.tsx b/src/components/input/ToolMultipleVideoInput.tsx index e3eede2..9f42b8a 100644 --- a/src/components/input/ToolMultipleVideoInput.tsx +++ b/src/components/input/ToolMultipleVideoInput.tsx @@ -1,92 +1,177 @@ -import React from 'react'; -import { Box, Typography, IconButton } from '@mui/material'; -import BaseFileInput from './BaseFileInput'; -import { BaseFileInputProps } from './file-input-utils'; -import DeleteIcon from '@mui/icons-material/Delete'; +import { ReactNode, useContext, useEffect, useRef, useState } from 'react'; +import { Box, useTheme } from '@mui/material'; +import Typography from '@mui/material/Typography'; +import InputHeader from '../InputHeader'; +import InputFooter from './InputFooter'; +import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; +import { isArray } from 'lodash'; +import VideoFileIcon from '@mui/icons-material/VideoFile'; -interface ToolMultipleVideoInputProps - extends Omit { - value: File[] | null; - onChange: (files: File[]) => void; - accept?: string[]; +interface MultiVideoInputComponentProps { + accept: string[]; title?: string; + type: 'video'; + value: MultiVideoInput[]; + onChange: (file: MultiVideoInput[]) => void; +} + +export interface MultiVideoInput { + file: File; + order: number; } export default function ToolMultipleVideoInput({ value, onChange, - accept = ['video/*', '.mkv'], + accept, title, - ...props -}: ToolMultipleVideoInputProps) { - // For preview, use the first file if available - const preview = - value && value.length > 0 ? URL.createObjectURL(value[0]) : undefined; + type +}: MultiVideoInputComponentProps) { + console.log('ToolMultipleVideoInput rendering with value:', value); - // Handler for file selection - const handleFileChange = (file: File | null) => { - if (file) { - // Add to existing files, avoiding duplicates by name - const files = value ? [...value] : []; - if (!files.some((f) => f.name === file.name && f.size === file.size)) { - onChange([...files, file]); - } - } + const fileInputRef = useRef(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const files = event.target.files; + console.log('File change event:', files); + if (files) + onChange([ + ...value, + ...Array.from(files).map((file) => ({ file, order: value.length })) + ]); }; - // Handler for removing a file - const handleRemove = (idx: number) => { - if (!value) return; - const newFiles = value.slice(); - newFiles.splice(idx, 1); - onChange(newFiles); + const handleImportClick = () => { + console.log('Import clicked'); + fileInputRef.current?.click(); + }; + + function handleClear() { + console.log('Clear clicked'); + onChange([]); + } + + function fileNameTruncate(fileName: string) { + const maxLength = 15; + if (fileName.length > maxLength) { + return fileName.slice(0, maxLength) + '...'; + } + return fileName; + } + + const sortList = () => { + const list = [...value]; + list.sort((a, b) => a.order - b.order); + onChange(list); + }; + + const reorderList = (sourceIndex: number, destinationIndex: number) => { + if (destinationIndex === sourceIndex) { + return; + } + const list = [...value]; + + if (destinationIndex === 0) { + list[sourceIndex].order = list[0].order - 1; + sortList(); + return; + } + + if (destinationIndex === list.length - 1) { + list[sourceIndex].order = list[list.length - 1].order + 1; + sortList(); + return; + } + + if (destinationIndex < sourceIndex) { + list[sourceIndex].order = + (list[destinationIndex].order + list[destinationIndex - 1].order) / 2; + sortList(); + return; + } + + list[sourceIndex].order = + (list[destinationIndex].order + list[destinationIndex + 1].order) / 2; + sortList(); }; return ( - - {() => ( + + + - {preview && ( - + + + + + ); } diff --git a/src/pages/tools/video/merge-video/index.tsx b/src/pages/tools/video/merge-video/index.tsx index 11f170b..fb35c0a 100644 --- a/src/pages/tools/video/merge-video/index.tsx +++ b/src/pages/tools/video/merge-video/index.tsx @@ -3,7 +3,9 @@ import React, { useState } from 'react'; import ToolContent from '@components/ToolContent'; import { ToolComponentProps } from '@tools/defineTool'; import ToolFileResult from '@components/result/ToolFileResult'; -import ToolMultipleVideoInput from '@components/input/ToolMultipleVideoInput'; +import ToolMultipleVideoInput, { + MultiVideoInput +} from '@components/input/ToolMultipleVideoInput'; import { main } from './service'; import { InitialValuesType } from './types'; @@ -13,28 +15,42 @@ export default function MergeVideo({ title, longDescription }: ToolComponentProps) { - const [input, setInput] = useState(null); + const [input, setInput] = useState([]); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); - const compute = async (_values: InitialValuesType, input: File[] | null) => { - if (!input || input.length < 2) return; + console.log('MergeVideo component rendering, input:', input); + + const compute = async ( + _values: InitialValuesType, + input: MultiVideoInput[] + ) => { + console.log('Compute called with input:', input); + if (!input || input.length < 2) { + console.log('Not enough files to merge'); + return; + } setLoading(true); try { - const mergedBlob = await main(input, initialValues); + const files = input.map((item) => item.file); + console.log( + 'Files to merge:', + files.map((f) => f.name) + ); + const mergedBlob = await main(files, initialValues); const mergedFile = new File([mergedBlob], 'merged-video.mp4', { type: 'video/mp4' }); setResult(mergedFile); + console.log('Merge successful'); } catch (err) { + console.error(`Failed to merge video: ${err}`); setResult(null); } finally { setLoading(false); } }; - const getGroups = () => []; - return ( { + console.log('Input changed:', newInput); + setInput(newInput); + }} + accept={['video/*', '.mp4', '.avi', '.mov', '.mkv']} title="Input Videos" + type="video" /> } resultComponent={ @@ -55,7 +76,7 @@ export default function MergeVideo({ /> } initialValues={initialValues} - getGroups={getGroups} + getGroups={null} setInput={setInput} compute={compute} toolInfo={{ title: `What is a ${title}?`, description: longDescription }} diff --git a/src/pages/tools/video/merge-video/merge-video.service.test.ts b/src/pages/tools/video/merge-video/merge-video.service.test.ts index 6efe27d..ee5a8b2 100644 --- a/src/pages/tools/video/merge-video/merge-video.service.test.ts +++ b/src/pages/tools/video/merge-video/merge-video.service.test.ts @@ -1,4 +1,22 @@ -import { expect, describe, it } from 'vitest'; +import { expect, describe, it, vi } from 'vitest'; + +// Mock FFmpeg and fetchFile to avoid Node.js compatibility issues +vi.mock('@ffmpeg/ffmpeg', () => ({ + FFmpeg: vi.fn().mockImplementation(() => ({ + loaded: false, + load: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])), + deleteFile: vi.fn().mockResolvedValue(undefined) + })) +})); + +vi.mock('@ffmpeg/util', () => ({ + fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])) +})); + +// Import after mocking import { main } from './service'; function createMockFile(name: string, type = 'video/mp4') { @@ -7,14 +25,28 @@ function createMockFile(name: string, type = 'video/mp4') { describe('merge-video', () => { it('throws if less than two files are provided', async () => { - await expect(main([], {})).rejects.toThrow(); - await expect(main([createMockFile('a.mp4')], {})).rejects.toThrow(); + await expect(main([], {})).rejects.toThrow( + 'Please provide at least two video files to merge.' + ); + await expect(main([createMockFile('a.mp4')], {})).rejects.toThrow( + 'Please provide at least two video files to merge.' + ); }); - it('merges two video files (mocked)', async () => { - // This will throw until ffmpeg logic is implemented - await expect( - main([createMockFile('a.mp4'), createMockFile('b.mp4')], {}) - ).rejects.toThrow('Video merging not yet implemented.'); + it('throws if input is not an array', async () => { + // @ts-ignore - testing invalid input + await expect(main(null, {})).rejects.toThrow( + 'Please provide at least two video files to merge.' + ); + }); + + it('successfully merges video files (mocked)', async () => { + const mockFile1 = createMockFile('video1.mp4'); + const mockFile2 = createMockFile('video2.mp4'); + + const result = await main([mockFile1, mockFile2], {}); + + expect(result).toBeInstanceOf(Blob); + expect(result.type).toBe('video/mp4'); }); }); diff --git a/src/pages/tools/video/merge-video/service.ts b/src/pages/tools/video/merge-video/service.ts index 71569a5..8a374d5 100644 --- a/src/pages/tools/video/merge-video/service.ts +++ b/src/pages/tools/video/merge-video/service.ts @@ -2,8 +2,6 @@ import { InitialValuesType, MergeVideoInput, MergeVideoOutput } from './types'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile } from '@ffmpeg/util'; -const ffmpeg = new FFmpeg(); - // This function will use ffmpeg.wasm to merge multiple video files in the browser. // Returns a Promise that resolves to a Blob of the merged video. export async function main( @@ -14,40 +12,62 @@ export async function main( throw new Error('Please provide at least two video files to merge.'); } - if (!ffmpeg.loaded) { - await ffmpeg.load({ - wasmURL: - 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' - }); - } + // Create a new FFmpeg instance for each operation to avoid conflicts + const ffmpeg = new FFmpeg(); - // Write all input files to ffmpeg FS - const fileNames = input.map((file, idx) => `input${idx}.mp4`); - for (let i = 0; i < input.length; i++) { - await ffmpeg.writeFile(fileNames[i], await fetchFile(input[i])); - } - - // Create concat list file - const concatList = fileNames.map((name) => `file '${name}'`).join('\n'); - await ffmpeg.writeFile( - 'concat_list.txt', - new TextEncoder().encode(concatList) - ); - - // Run ffmpeg concat demuxer + const fileNames: string[] = []; const outputName = 'output.mp4'; - await ffmpeg.exec([ - '-f', - 'concat', - '-safe', - '0', - '-i', - 'concat_list.txt', - '-c', - 'copy', - outputName - ]); - const mergedData = await ffmpeg.readFile(outputName); - return new Blob([mergedData], { type: 'video/mp4' }); + try { + // Load FFmpeg + if (!ffmpeg.loaded) { + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); + } + + // Write all input files to ffmpeg FS + fileNames.push(...input.map((file, idx) => `input${idx}.mp4`)); + for (let i = 0; i < input.length; i++) { + await ffmpeg.writeFile(fileNames[i], await fetchFile(input[i])); + } + + // Create concat list file + const concatList = fileNames.map((name) => `file '${name}'`).join('\n'); + await ffmpeg.writeFile( + 'concat_list.txt', + new TextEncoder().encode(concatList) + ); + + // Run ffmpeg concat demuxer + await ffmpeg.exec([ + '-f', + 'concat', + '-safe', + '0', + '-i', + 'concat_list.txt', + '-c', + 'copy', + outputName + ]); + + const mergedData = await ffmpeg.readFile(outputName); + return new Blob([mergedData], { type: 'video/mp4' }); + } catch (error) { + console.error('Error merging videos:', error); + throw new Error(`Failed to merge videos: ${error}`); + } finally { + // Clean up temporary files + try { + for (const fileName of fileNames) { + await ffmpeg.deleteFile(fileName); + } + await ffmpeg.deleteFile('concat_list.txt'); + await ffmpeg.deleteFile(outputName); + } catch (cleanupError) { + console.warn('Error cleaning up temporary files:', cleanupError); + } + } }