diff --git a/src/components/input/ToolMultipleVideoInput.tsx b/src/components/input/ToolMultipleVideoInput.tsx new file mode 100644 index 0000000..e3eede2 --- /dev/null +++ b/src/components/input/ToolMultipleVideoInput.tsx @@ -0,0 +1,92 @@ +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'; + +interface ToolMultipleVideoInputProps + extends Omit { + value: File[] | null; + onChange: (files: File[]) => void; + accept?: string[]; + title?: string; +} + +export default function ToolMultipleVideoInput({ + value, + onChange, + accept = ['video/*', '.mkv'], + title, + ...props +}: ToolMultipleVideoInputProps) { + // For preview, use the first file if available + const preview = + value && value.length > 0 ? URL.createObjectURL(value[0]) : undefined; + + // 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]); + } + } + }; + + // Handler for removing a file + const handleRemove = (idx: number) => { + if (!value) return; + const newFiles = value.slice(); + newFiles.splice(idx, 1); + onChange(newFiles); + }; + + return ( + + {() => ( + + {preview && ( + + )} + + ); +} diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts index 7cbf9f9..be61367 100644 --- a/src/pages/tools/video/index.ts +++ b/src/pages/tools/video/index.ts @@ -1,3 +1,4 @@ +import { tool as videoMergeVideo } from './merge-video/meta'; import { tool as videoToGif } from './video-to-gif/meta'; import { tool as changeSpeed } from './change-speed/meta'; import { tool as flipVideo } from './flip/meta'; @@ -17,5 +18,6 @@ export const videoTools = [ flipVideo, cropVideo, changeSpeed, - videoToGif + videoToGif, + videoMergeVideo ]; diff --git a/src/pages/tools/video/merge-video/index.tsx b/src/pages/tools/video/merge-video/index.tsx new file mode 100644 index 0000000..11f170b --- /dev/null +++ b/src/pages/tools/video/merge-video/index.tsx @@ -0,0 +1,64 @@ +import { Box } from '@mui/material'; +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 { main } from './service'; +import { InitialValuesType } from './types'; + +const initialValues: InitialValuesType = {}; + +export default function MergeVideo({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const compute = async (_values: InitialValuesType, input: File[] | null) => { + if (!input || input.length < 2) return; + setLoading(true); + try { + const mergedBlob = await main(input, initialValues); + const mergedFile = new File([mergedBlob], 'merged-video.mp4', { + type: 'video/mp4' + }); + setResult(mergedFile); + } catch (err) { + setResult(null); + } finally { + setLoading(false); + } + }; + + const getGroups = () => []; + + return ( + + } + resultComponent={ + + } + initialValues={initialValues} + getGroups={getGroups} + 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 new file mode 100644 index 0000000..6efe27d --- /dev/null +++ b/src/pages/tools/video/merge-video/merge-video.service.test.ts @@ -0,0 +1,20 @@ +import { expect, describe, it } from 'vitest'; +import { main } from './service'; + +function createMockFile(name: string, type = 'video/mp4') { + return new File([new Uint8Array([0, 1, 2])], name, { type }); +} + +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(); + }); + + 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.'); + }); +}); diff --git a/src/pages/tools/video/merge-video/meta.ts b/src/pages/tools/video/merge-video/meta.ts new file mode 100644 index 0000000..7f459c5 --- /dev/null +++ b/src/pages/tools/video/merge-video/meta.ts @@ -0,0 +1,14 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('video', { + name: 'Merge Videos', + path: 'merge-video', + icon: 'merge_type', // Material icon for merging + description: 'Combine multiple video files into one continuous video.', + shortDescription: 'Append and merge videos easily.', + keywords: ['merge', 'video', 'append', 'combine'], + longDescription: + 'This tool allows you to merge or append multiple video files into a single continuous video. Simply upload your video files, arrange them in the desired order, and merge them into one file for easy sharing or editing.', + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/video/merge-video/service.ts b/src/pages/tools/video/merge-video/service.ts new file mode 100644 index 0000000..71569a5 --- /dev/null +++ b/src/pages/tools/video/merge-video/service.ts @@ -0,0 +1,53 @@ +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( + input: MergeVideoInput, + options: InitialValuesType +): Promise { + if (!Array.isArray(input) || input.length < 2) { + 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' + }); + } + + // 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 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' }); +} diff --git a/src/pages/tools/video/merge-video/types.ts b/src/pages/tools/video/merge-video/types.ts new file mode 100644 index 0000000..7cb6a52 --- /dev/null +++ b/src/pages/tools/video/merge-video/types.ts @@ -0,0 +1,9 @@ +export type InitialValuesType = { + // Add any future options here (e.g., output format, resolution) +}; + +// Type for the main function input +export type MergeVideoInput = File[]; + +// Type for the main function output +export type MergeVideoOutput = Blob;