├── .eslintrc.cjs ├── .gitignore ├── README.md ├── components.json ├── dist ├── assets │ └── worker-lPYB70QI.js ├── index.html └── vite.svg ├── index.html ├── index.md ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── logo.svg └── vite.svg ├── src ├── assets │ └── react.svg ├── components │ ├── DemoFFmpeg.tsx │ ├── DropZone.tsx │ ├── Header.tsx │ ├── Layout.tsx │ ├── Linker.tsx │ ├── SlidebarNav.tsx │ ├── VideoClipper │ │ ├── DragHandler.tsx │ │ └── index.tsx │ ├── bit-rate-selector.tsx │ ├── color-space-selector.tsx │ ├── file-type-selector.tsx │ ├── icons │ │ └── index.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── slider.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── hooks │ └── use-mutation-observer.ts ├── lib │ ├── FFmpeg.wasm.ts │ ├── ffmpeg-js.bak │ └── utils.ts ├── main.tsx ├── pages │ ├── 404.tsx │ ├── More.tsx │ └── VideoToX.tsx ├── routes │ ├── index.tsx │ └── typings.ts ├── styles │ └── index.css └── vite-env.d.ts ├── styles └── index.css ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | "@typescript-eslint/no-var-requires": false 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | dist 11 | node_modules 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | 一个提供丰富参数,多格式导出的在线 视频转换/压缩 web 应用。 4 | 5 | ## How to use ? 6 | 7 | demo 演示 [bilibili](https://www.bilibili.com/video/BV1KMvTe5ExN/?share_source=copy_web&vd_source=534796ceeed8cbf786fbf5ec32abc7a0), [youtube](https://www.youtube.com/embed/SDm7EcHQV_k). 8 | 9 | 10 | ## TODO 11 | - [x] 视频裁剪 12 | - [x] 压缩级别支持 13 | - [x] 颜色空间转换 14 | - [x] 输出文件名修改 15 | - [x] 多线程自动开启 16 | - [ ] 播放指针 17 | - [ ] 指令复制 18 | - [x] 使用示例视频 -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "styles/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /dist/assets/worker-lPYB70QI.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";const R="https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js";var E;(function(t){t.LOAD="LOAD",t.EXEC="EXEC",t.WRITE_FILE="WRITE_FILE",t.READ_FILE="READ_FILE",t.DELETE_FILE="DELETE_FILE",t.RENAME="RENAME",t.CREATE_DIR="CREATE_DIR",t.LIST_DIR="LIST_DIR",t.DELETE_DIR="DELETE_DIR",t.ERROR="ERROR",t.DOWNLOAD="DOWNLOAD",t.PROGRESS="PROGRESS",t.LOG="LOG",t.MOUNT="MOUNT",t.UNMOUNT="UNMOUNT"})(E||(E={}));const a=new Error("unknown message type"),f=new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"),u=new Error("failed to import ffmpeg-core.js");let r;const O=async({coreURL:t,wasmURL:n,workerURL:e})=>{const o=!r;try{t||(t=R),importScripts(t)}catch{if(t||(t=R.replace("/umd/","/esm/")),self.createFFmpegCore=(await import(t)).default,!self.createFFmpegCore)throw u}const s=t,c=n||t.replace(/.js$/g,".wasm"),b=e||t.replace(/.js$/g,".worker.js");return r=await self.createFFmpegCore({mainScriptUrlOrBlob:`${s}#${btoa(JSON.stringify({wasmURL:c,workerURL:b}))}`}),r.setLogger(i=>self.postMessage({type:E.LOG,data:i})),r.setProgress(i=>self.postMessage({type:E.PROGRESS,data:i})),o},l=({args:t,timeout:n=-1})=>{r.setTimeout(n),r.exec(...t);const e=r.ret;return r.reset(),e},m=({path:t,data:n})=>(r.FS.writeFile(t,n),!0),D=({path:t,encoding:n})=>r.FS.readFile(t,{encoding:n}),S=({path:t})=>(r.FS.unlink(t),!0),I=({oldPath:t,newPath:n})=>(r.FS.rename(t,n),!0),L=({path:t})=>(r.FS.mkdir(t),!0),N=({path:t})=>{const n=r.FS.readdir(t),e=[];for(const o of n){const s=r.FS.stat(`${t}/${o}`),c=r.FS.isDir(s.mode);e.push({name:o,isDir:c})}return e},A=({path:t})=>(r.FS.rmdir(t),!0),w=({fsType:t,options:n,mountPoint:e})=>{const o=t,s=r.FS.filesystems[o];return s?(r.FS.mount(s,n,e),!0):!1},k=({mountPoint:t})=>(r.FS.unmount(t),!0);self.onmessage=async({data:{id:t,type:n,data:e}})=>{const o=[];let s;try{if(n!==E.LOAD&&!r)throw f;switch(n){case E.LOAD:s=await O(e);break;case E.EXEC:s=l(e);break;case E.WRITE_FILE:s=m(e);break;case E.READ_FILE:s=D(e);break;case E.DELETE_FILE:s=S(e);break;case E.RENAME:s=I(e);break;case E.CREATE_DIR:s=L(e);break;case E.LIST_DIR:s=N(e);break;case E.DELETE_DIR:s=A(e);break;case E.MOUNT:s=w(e);break;case E.UNMOUNT:s=k(e);break;default:throw a}}catch(c){self.postMessage({id:t,type:E.ERROR,data:c.toString()});return}s instanceof Uint8Array&&o.push(s.buffer),self.postMessage({id:t,type:n,data:s},o)}})(); 2 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | FFmpeg Convertor 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /dist/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | FFmpeg Convertor 9 | 10 | 11 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | FFmpeg Convertor 转换器 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffmpeg-convertor", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "checkbuild": "tsc -b && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview", 12 | "add-comp": "npx shadcn-ui@latest add" 13 | }, 14 | "dependencies": { 15 | "@ffmpeg/ffmpeg": "^0.12.10", 16 | "@ffmpeg/util": "^0.12.1", 17 | "@hookform/resolvers": "^3.9.0", 18 | "@radix-ui/react-dialog": "^1.1.1", 19 | "@radix-ui/react-hover-card": "^1.1.1", 20 | "@radix-ui/react-icons": "^1.3.0", 21 | "@radix-ui/react-label": "^2.1.0", 22 | "@radix-ui/react-popover": "^1.1.1", 23 | "@radix-ui/react-progress": "^1.1.0", 24 | "@radix-ui/react-select": "^2.1.1", 25 | "@radix-ui/react-separator": "^1.1.0", 26 | "@radix-ui/react-slider": "^1.2.0", 27 | "@radix-ui/react-slot": "^1.1.0", 28 | "@radix-ui/react-toast": "^1.2.1", 29 | "@radix-ui/react-tooltip": "^1.1.2", 30 | "bowser": "^2.11.0", 31 | "class-variance-authority": "^0.7.0", 32 | "clsx": "^2.1.1", 33 | "cmdk": "^1.0.0", 34 | "react": "^18.3.1", 35 | "react-dom": "^18.3.1", 36 | "react-hook-form": "^7.52.1", 37 | "react-router-dom": "^6.25.1", 38 | "tailwind-merge": "^2.4.0", 39 | "tailwindcss-animate": "^1.0.7", 40 | "zod": "^3.23.8" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^20.14.12", 44 | "@types/react": "^18.3.3", 45 | "@types/react-dom": "^18.3.0", 46 | "@typescript-eslint/eslint-plugin": "^7.15.0", 47 | "@typescript-eslint/parser": "^7.15.0", 48 | "@vitejs/plugin-react": "^4.3.1", 49 | "autoprefixer": "^10.4.19", 50 | "eslint": "^8.57.0", 51 | "eslint-plugin-react-hooks": "^4.6.2", 52 | "eslint-plugin-react-refresh": "^0.4.7", 53 | "postcss": "^8.4.40", 54 | "tailwindcss": "^3.4.6", 55 | "typescript": "^5.2.2", 56 | "vite": "^5.3.4" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/DemoFFmpeg.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react"; 2 | import { FFmpeg } from "@ffmpeg/ffmpeg"; 3 | import { toBlobURL, fetchFile } from "@ffmpeg/util"; 4 | 5 | export default function DemoFFmpeg() { 6 | const [loaded, setLoaded] = useState(false); 7 | const ffmpegRef = useRef(new FFmpeg()); 8 | const videoRef = useRef(null) 9 | const messageRef = useRef(null) 10 | 11 | const load = async () => { 12 | try { 13 | console.log('1',1) 14 | const baseURL = "https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm"; 15 | const ffmpeg = ffmpegRef.current; 16 | ffmpeg.on("log", ({ message }) => { 17 | if (messageRef.current) messageRef.current.innerHTML = message; 18 | }); 19 | console.log('2',2) 20 | // toBlobURL is used to bypass CORS issue, urls with the same 21 | // domain can be used directly. 22 | await ffmpeg.load({ 23 | coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"), 24 | wasmURL: await toBlobURL( 25 | `${baseURL}/ffmpeg-core.wasm`, 26 | "application/wasm" 27 | ), 28 | workerURL: await toBlobURL( 29 | `${baseURL}/ffmpeg-core.worker.js`, 30 | "text/javascript" 31 | ), 32 | }); 33 | console.log('3',3) 34 | console.log("trigger") 35 | setLoaded(true); 36 | } catch (error) { 37 | console.error(error); 38 | 39 | } 40 | }; 41 | 42 | const transcode = async () => { 43 | const videoURL = "https://raw.githubusercontent.com/ffmpegwasm/testdata/master/video-15s.avi"; 44 | const ffmpeg = ffmpegRef.current; 45 | await ffmpeg.writeFile("input.avi", await fetchFile(videoURL)); 46 | await ffmpeg.exec(["-i", "input.avi", "output.mp4"]); 47 | const fileData = await ffmpeg.readFile('output.mp4'); 48 | const data = new Uint8Array(fileData as ArrayBuffer); 49 | if (videoRef.current) { 50 | videoRef.current.src = URL.createObjectURL( 51 | new Blob([data.buffer], { type: 'video/mp4' }) 52 | ) 53 | } 54 | }; 55 | 56 | return loaded ? ( 57 | <> 58 | 59 |
60 | 61 |

62 | 63 | ) : ( 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/components/DropZone.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; 2 | import { Card, CardContent } from '@/components/ui/card'; 3 | import { Button } from '@/components/ui/button'; 4 | 5 | // Define the props expected by the Dropzone component 6 | interface DropzoneProps { 7 | // onChange: React.Dispatch>; 8 | onChange: (files:File[])=>void; 9 | className?: string; 10 | fileExtension?: string; 11 | 12 | } 13 | 14 | // Create the Dropzone component receiving props 15 | export default forwardRef(({ 16 | onChange, 17 | className, 18 | fileExtension, 19 | ...props 20 | }: DropzoneProps, ref) =>{ 21 | // Initialize state variables using the useState hook 22 | const fileInputRef = useRef(null); // Reference to file input element 23 | const [fileInfo, setFileInfo] = useState(null); // Information about the uploaded file 24 | const [error, setError] = useState(null); // Error message state 25 | 26 | 27 | useImperativeHandle(ref, () => { 28 | console.log('useImperativeHandle called'); 29 | return { 30 | inputElement: fileInputRef.current 31 | } 32 | }); 33 | 34 | 35 | // Function to handle drag over event 36 | const handleDragOver = (e: React.DragEvent) => { 37 | e.preventDefault(); 38 | e.stopPropagation(); 39 | }; 40 | 41 | // Function to handle drop event 42 | const handleDrop = (e: React.DragEvent) => { 43 | e.preventDefault(); 44 | e.stopPropagation(); 45 | const { files } = e.dataTransfer; 46 | handleFiles(files); 47 | }; 48 | 49 | // Function to handle file input change event 50 | const handleFileInputChange = (e: React.ChangeEvent) => { 51 | const { files } = e.target; 52 | if (files) { 53 | handleFiles(files); 54 | } 55 | }; 56 | 57 | // Function to handle processing of uploaded files 58 | const handleFiles = (files: FileList) => { 59 | const uploadedFile = files[0]; 60 | 61 | // Check file extension 62 | // if (fileExtension && !uploadedFile.name.endsWith(`.${fileExtension}`)) { 63 | // setError(`Invalid file type. Expected: .${fileExtension}`); 64 | // return; 65 | // } 66 | 67 | const fileSizeInKB = Math.round(uploadedFile.size / 1024); // Convert to KB 68 | 69 | // const fileList = Array.from(files).map((file) => URL.createObjectURL(file)); 70 | const fileList = Array.from(files); 71 | // onChange((prevFiles) => [...prevFiles, ...fileList]); 72 | onChange([...fileList]); 73 | 74 | // Display file information 75 | setFileInfo(`Uploaded file: ${uploadedFile.name} (${fileSizeInKB} KB)`); 76 | setError(null); // Reset error state 77 | }; 78 | 79 | // Function to simulate a click on the file input element 80 | const handleButtonClick = () => { 81 | if (fileInputRef.current) { 82 | fileInputRef.current.click(); 83 | } 84 | }; 85 | 86 | return ( 87 | 91 | 96 |
97 | Drag Files to Upload or 98 | 106 | 113 |
114 | {fileInfo &&

{fileInfo}

} 115 | {error && {error}} 116 |
117 |
118 | ); 119 | }) -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { GitHubLogoIcon } from '@radix-ui/react-icons' 3 | 4 | export default function Header() { 5 | return
6 |
7 |

FFmpeg Convertor 8 | 9 | 10 | 11 |

12 |

Easily convertor, powered by ffmpeg.

13 |
14 |
15 |
16 |
17 | } -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/routes/typings"; 2 | import { SidebarNav } from "./SlidebarNav"; 3 | import { Outlet } from "react-router-dom"; 4 | import { Separator } from "@radix-ui/react-separator"; 5 | import Header from "./Header"; 6 | import { cn } from "@/lib/utils"; 7 | import { Toaster } from "@/components/ui/toaster" 8 | const sidebarNavItems = [ 9 | { 10 | title: "Video to X", 11 | href: ROUTES.VIDEO_TO_X, 12 | }, 13 | { 14 | title: "More", 15 | href: ROUTES.MORE, 16 | }, 17 | ]; 18 | 19 | interface SettingsLayoutProps { 20 | children: React.ReactNode; 21 | } 22 | // { children }: SettingsLayoutProps 23 | export default function Layout() { 24 | return ( 25 |
26 |
27 | 28 |
29 | 32 |
33 | 34 |
35 |
36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Linker.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Link } from "react-router-dom"; 3 | 4 | interface LinkerProps extends React.HTMLAttributes { 5 | to: string 6 | name: string 7 | } 8 | 9 | export default function Linker({ to, name, className }: LinkerProps) { 10 | return ( 11 | 39 | {name} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/SlidebarNav.tsx: -------------------------------------------------------------------------------- 1 | interface SidebarNavProps extends React.HTMLAttributes { 2 | items: { 3 | href: string 4 | title: string 5 | }[] 6 | } 7 | import { cn } from "@/lib/utils" 8 | import Linker from "./Linker" 9 | import { useLocation } from "react-router-dom" 10 | export function SidebarNav({ className, items, ...props }: SidebarNavProps) { 11 | const pathname = useLocation().pathname 12 | return ( 13 | 36 | ) 37 | } -------------------------------------------------------------------------------- /src/components/VideoClipper/DragHandler.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect, useRef, useState } from "react"; 2 | import { MaterialSymbolsDragIndicator } from "../icons"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | export enum HandlerEnum { 6 | START = "start", 7 | END = "end", 8 | } 9 | interface DragHandlerPropsType 10 | extends React.HtmlHTMLAttributes { 11 | type: HandlerEnum; 12 | onPositionChange: (position: number, percentage: number) => void; 13 | initRange: (isStart: boolean, position: number) => void; 14 | } 15 | export default function DragHandler({ 16 | type, 17 | onPositionChange, 18 | initRange, 19 | children, 20 | }: DragHandlerPropsType) { 21 | const offset = type === HandlerEnum.START ? 0 : 20; 22 | const [position, setPosition] = useState(offset); 23 | const refHandler = useRef(null); 24 | 25 | useEffect(() => { 26 | function init() { 27 | const { width: parentElementWidth } = 28 | refHandler.current?.parentElement?.getBoundingClientRect() ?? { 29 | x: 0, 30 | width: 0, 31 | }; 32 | const initPosition = 33 | type === HandlerEnum.START 34 | ? 0 35 | : parentElementWidth 36 | setPosition(initPosition); 37 | initRange(type === HandlerEnum.START, initPosition); 38 | } 39 | init(); 40 | window.addEventListener("resize", init); 41 | // onPositionChange(initPosition, type === HandlerEnum.START ? 0 : 100) 42 | }, []); 43 | 44 | let dragDeltaX = 0; 45 | let startPosition = 0; 46 | 47 | // 处理鼠标按下逻辑 48 | const handleMouseDown = (e: React.MouseEvent) => { 49 | document.addEventListener("mousemove", handleMouseMove); 50 | document.addEventListener("mouseup", handleMouseUp); 51 | const { x: parentElementX } = 52 | refHandler.current?.parentElement?.getBoundingClientRect() ?? { 53 | x: 0, 54 | width: 0, 55 | }; 56 | startPosition = parentElementX; 57 | }; 58 | 59 | // 处理松开鼠标后的逻辑 60 | const handleMouseUp = (e: MouseEvent) => { 61 | e.preventDefault(); 62 | console.log("mouseup event"); 63 | document.removeEventListener("mousemove", handleMouseMove); 64 | document.removeEventListener("mouseup", handleMouseUp); 65 | }; 66 | 67 | // 处理拖动逻辑 68 | const handleMouseMove = (e: MouseEvent) => { 69 | e.preventDefault(); 70 | dragDeltaX = e.clientX - startPosition; 71 | 72 | const { x: parentElementX, width: parentElementWidth } = 73 | refHandler.current?.parentElement?.getBoundingClientRect() ?? { 74 | x: 0, 75 | width: 0, 76 | }; 77 | const handlerWidth = refHandler.current?.offsetWidth || 0; 78 | 79 | // default 80 | const default_minX = parentElementX - startPosition + offset; 81 | const default_maxX = parentElementWidth; 82 | let minX = default_minX; 83 | let maxX = default_maxX; 84 | const anotherHandler = 85 | type === HandlerEnum.START 86 | ? (refHandler.current?.nextElementSibling as HTMLElement) 87 | : (refHandler.current?.previousElementSibling as HTMLElement); 88 | 89 | if (type === HandlerEnum.START) {// 左边手柄 90 | maxX = 91 | anotherHandler?.offsetLeft! 92 | minX = parentElementX - startPosition + offset; 93 | } else {// 右边手柄 94 | maxX = parentElementWidth; 95 | minX = anotherHandler?.offsetLeft!; 96 | } 97 | 98 | if (dragDeltaX > minX && dragDeltaX < maxX) { 99 | setPosition(dragDeltaX); 100 | onPositionChange( 101 | dragDeltaX, 102 | dragDeltaX / parentElementWidth 103 | ); 104 | } 105 | }; 106 | 107 | return ( 108 |
handleMouseDown(e)} 111 | style={{ left: position }} 112 | className="left-handler z-20 h-[150%] w-4 -translate-x-1/2 rounded-full bg-white absolute cursor-pointer flex justify-center items-center" 113 | > 114 | 115 | 116 | {children} 117 | 118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/components/VideoClipper/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useEffect, useRef, useState } from "react"; 2 | import DragHandler, { HandlerEnum } from "./DragHandler"; 3 | import { cn, secondsToTime, throttle } from "@/lib/utils"; 4 | import { 5 | IcRoundPause, 6 | IcRoundPlayArrow, 7 | MaterialSymbolsVolumeUp, 8 | } from "../icons"; 9 | 10 | interface VideoClipperProps extends React.HTMLAttributes { 11 | src: string; 12 | onClipChange: (startPoint: string, endPoint: string) => void; 13 | } 14 | 15 | export default function VideoClipper({ 16 | onClipChange, 17 | ...props 18 | }: VideoClipperProps) { 19 | const [play, setPlay] = useState(false); 20 | const [range, setRange] = useState([0, 0]); 21 | const [startPoint, setStartPoint] = useState("");// 时间 tag 22 | const [endPoint, setEndPoint] = useState(""); 23 | 24 | // const [startTime, setStartTime] = useState(0);// 时间 tag 25 | // const [endTime, setEndTime] = useState(0); 26 | // xTimeRef 用于解决 useEffect 闭包陷阱 27 | const startTimeRef = useRef(0); 28 | const endTimeRef = useRef(0); 29 | const setStartTime = (val: number)=>{startTimeRef.current = val} 30 | const setEndTime = (val: number)=>{endTimeRef.current = val} 31 | 32 | const [volumVisible, setVolumVisible] = useState(false); 33 | const [duration, setDuration] = useState(0) 34 | const videoRef = useRef(null); 35 | 36 | useEffect(() => { 37 | const video = videoRef.current; 38 | if (!video) return; 39 | 40 | // 初始化 时间 tag 41 | const handleLoadedmetadata = () => { 42 | const duration = video.duration 43 | setStartPoint(secondsToTime(0)) 44 | setEndPoint(secondsToTime(duration)) 45 | setDuration(duration) 46 | setStartTime(0) 47 | setEndTime(duration) 48 | } 49 | 50 | // 同步播放按钮 51 | const handlePlay = () => setPlay(true); 52 | const handlePause = () => setPlay(false); 53 | // 自动循环播放, 让视频始终在指定范围内重复播放 54 | const handleTimeupdate = ()=>{ 55 | // ! 注意:这里的回调触发频率是没有特别高的, 可能会导致听感的时间间隔和实际导出的参数设定有一秒左右的误差, 如果需要非常精确, 可以使用 requestAniamtion 56 | if(videoRef.current && videoRef.current.currentTime > endTimeRef.current){ 57 | playAtFromRangeStart() 58 | } 59 | } 60 | 61 | video.addEventListener("play", handlePlay); 62 | video.addEventListener("pause", handlePause); 63 | video.addEventListener("loadedmetadata", handleLoadedmetadata) 64 | video.addEventListener("timeupdate", handleTimeupdate) 65 | 66 | 67 | 68 | return () => { 69 | video.removeEventListener("play", handlePlay); 70 | video.removeEventListener("pause", handlePause); 71 | video.removeEventListener("loadedmetadata", handleLoadedmetadata) 72 | video.removeEventListener("timeupdate", handleTimeupdate) 73 | 74 | 75 | }; 76 | }, []); 77 | 78 | // 初始化 range 条 79 | const initRange = (isStart: boolean, e: number) => updateRange(isStart, e); 80 | // 更新 range 条 81 | const updateRange = (isStart: boolean, e: number) => { 82 | const newRange = Array.from(range); 83 | newRange[isStart ? 0 : 1] = e; 84 | setRange(newRange); 85 | }; 86 | const handleChange = (isStart: boolean, e: number, percentage: number) => { 87 | updateRange(isStart, e); 88 | const newTime = duration * percentage 89 | const formatedTimeStr = secondsToTime(newTime); 90 | 91 | // xTimeRef 用于解决 useEffect 闭包陷阱 92 | isStart ? startTimeRef.current = newTime : endTimeRef.current = newTime; 93 | isStart ? setStartTime(newTime) : setEndTime(newTime); 94 | 95 | isStart ? setStartPoint(formatedTimeStr) : setEndPoint(formatedTimeStr); 96 | 97 | 98 | 99 | // 起始时刻 + 持续时间 100 | const lastingTime = endTimeRef.current - startTimeRef.current 101 | onClipChange(startPoint, secondsToTime(lastingTime)); 102 | throttledPlay(); 103 | }; 104 | 105 | const handlePlay = function () { 106 | play ? videoRef.current?.pause() : videoRef.current?.play(); 107 | setPlay(!play); 108 | }; 109 | const handleVolChange = (event: ChangeEvent) => { 110 | const target = event.target as HTMLInputElement; 111 | videoRef.current && (videoRef.current.volume = Number(target.value)); 112 | }; 113 | 114 | // 设定播放时间点并播放 115 | const playAtFromRangeStart = () => { 116 | if (videoRef.current) { 117 | videoRef.current.currentTime = startTimeRef.current; 118 | videoRef.current.play(); 119 | } 120 | }; 121 | const throttledPlay = throttle(playAtFromRangeStart, 100); 122 | 123 | return ( 124 |
125 | 133 |
134 | 140 |
141 | handleChange(true, e, p)} 144 | type={HandlerEnum.START} 145 | > 146 | {startPoint} 147 | 148 | handleChange(false, e, p)} 151 | type={HandlerEnum.END} 152 | > 153 | {endPoint} 154 | 155 |
159 |
160 | 166 | {volumVisible && ( 167 | handleVolChange(e)} 169 | className={cn( 170 | "absolute -right-14 -top-0 -rotate-90 transition-all w-20 ", 171 | "appearance-none bg-transparent", 172 | "[&::-webkit-slider-runnable-track]:rounded-full", 173 | " [&::-webkit-slider-runnable-track]:bg-black/80", 174 | " [&::-webkit-slider-runnable-track]:backdrop-blur-2xl", 175 | "[&::-webkit-slider-thumb]:appearance-none ", 176 | "[&::-webkit-slider-thumb]:h-4 ", 177 | "[&::-webkit-slider-thumb]:w-4 ", 178 | "[&::-webkit-slider-thumb]:rounded-full ", 179 | "[&::-webkit-slider-thumb]:bg-white" 180 | )} 181 | type="range" 182 | id="volumeSlider" 183 | min="0" 184 | max="1" 185 | step="0.01" 186 | > 187 | )} 188 |
189 | {props.children} 190 |
191 | ); 192 | } 193 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /src/components/bit-rate-selector.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectGroup, 5 | SelectItem, 6 | SelectLabel, 7 | SelectTrigger, 8 | SelectValue, 9 | } from "@/components/ui/select" 10 | 11 | interface BiteRateSelectorProps extends React.HTMLAttributes { 12 | onValueChange: (value: string)=>void 13 | defaultValue: string 14 | } 15 | 16 | export default function BiteRateSelector({defaultValue,onValueChange}: BiteRateSelectorProps) { 17 | return ( 18 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/components/color-space-selector.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectGroup, 5 | SelectItem, 6 | SelectLabel, 7 | SelectTrigger, 8 | SelectValue, 9 | } from "@/components/ui/select"; 10 | 11 | interface BiteRateSelectorProps extends React.HTMLAttributes { 12 | onValueChange: (value: string) => void; 13 | defaultValue?: string; 14 | } 15 | 16 | export default function ColorSpaceSelector({ 17 | defaultValue, 18 | onValueChange, 19 | }: BiteRateSelectorProps) { 20 | return ( 21 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/file-type-selector.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/components/ui/select"; 8 | 9 | interface BiteRateSelectorProps extends React.HTMLAttributes { 10 | onValueChange: (value: string) => void; 11 | defaultValue: string; 12 | } 13 | 14 | export default function FileTypeSelector({ 15 | defaultValue, 16 | onValueChange, 17 | }: BiteRateSelectorProps) { 18 | return ( 19 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export function IcRoundPlayArrow(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | export function IcRoundPause(props: SVGProps) { 20 | return ( 21 | 28 | 32 | 33 | ); 34 | } 35 | 36 | export function MaterialSymbolsVolumeUp(props: SVGProps) { 37 | return ( 38 | 45 | 49 | 50 | ); 51 | } 52 | 53 | 54 | export function MingcuteCloseFill(props: SVGProps) { 55 | return ( 56 | 63 | 64 | 65 | 69 | 70 | 71 | ); 72 | } 73 | 74 | 75 | 76 | export function HugeiconsLoading03(props: SVGProps) { 77 | return ( 78 | 79 | ) 80 | } 81 | 82 | 83 | 84 | 85 | 86 | export function MaterialSymbolsDragIndicator(props: SVGProps) { 87 | return ( 88 | 89 | ) 90 | } -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { type DialogProps } from "@radix-ui/react-dialog" 3 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons" 4 | import { Command as CommandPrimitive } from "cmdk" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Dialog, DialogContent } from "@/components/ui/dialog" 8 | 9 | const Command = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | Command.displayName = CommandPrimitive.displayName 23 | 24 | interface CommandDialogProps extends DialogProps {} 25 | 26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |