├── .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 |
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 |
17 | );
18 | }
19 | export function IcRoundPause(props: SVGProps) {
20 | return (
21 |
33 | );
34 | }
35 |
36 | export function MaterialSymbolsVolumeUp(props: SVGProps) {
37 | return (
38 |
50 | );
51 | }
52 |
53 |
54 | export function MingcuteCloseFill(props: SVGProps) {
55 | return (
56 |
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 |
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 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const HoverCard = HoverCardPrimitive.Root
7 |
8 | const HoverCardTrigger = HoverCardPrimitive.Trigger
9 |
10 | const HoverCardContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
24 | ))
25 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
26 |
27 | export { HoverCard, HoverCardTrigger, HoverCardContent }
28 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as PopoverPrimitive from "@radix-ui/react-popover"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Popover = PopoverPrimitive.Root
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger
9 |
10 | const PopoverAnchor = PopoverPrimitive.Anchor
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
32 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ProgressPrimitive from "@radix-ui/react-progress"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Progress = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, value, ...props }, ref) => (
10 |
18 |
22 |
23 | ))
24 | Progress.displayName = ProgressPrimitive.Root.displayName
25 |
26 | export { Progress }
27 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | CaretSortIcon,
4 | CheckIcon,
5 | ChevronDownIcon,
6 | ChevronUpIcon,
7 | } from "@radix-ui/react-icons"
8 | import * as SelectPrimitive from "@radix-ui/react-select"
9 |
10 | import { cn } from "@/lib/utils"
11 |
12 | const Select = SelectPrimitive.Root
13 |
14 | const SelectGroup = SelectPrimitive.Group
15 |
16 | const SelectValue = SelectPrimitive.Value
17 |
18 | const SelectTrigger = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, children, ...props }, ref) => (
22 | span]:line-clamp-1",
26 | className
27 | )}
28 | {...props}
29 | >
30 | {children}
31 |
32 |
33 |
34 |
35 | ))
36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
37 |
38 | const SelectScrollUpButton = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 |
51 |
52 | ))
53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
54 |
55 | const SelectScrollDownButton = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
67 |
68 |
69 | ))
70 | SelectScrollDownButton.displayName =
71 | SelectPrimitive.ScrollDownButton.displayName
72 |
73 | const SelectContent = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, children, position = "popper", ...props }, ref) => (
77 |
78 |
89 |
90 |
97 | {children}
98 |
99 |
100 |
101 |
102 | ))
103 | SelectContent.displayName = SelectPrimitive.Content.displayName
104 |
105 | const SelectLabel = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SelectLabel.displayName = SelectPrimitive.Label.displayName
116 |
117 | const SelectItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ))
137 | SelectItem.displayName = SelectPrimitive.Item.displayName
138 |
139 | const SelectSeparator = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef
142 | >(({ className, ...props }, ref) => (
143 |
148 | ))
149 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
150 |
151 | export {
152 | Select,
153 | SelectGroup,
154 | SelectValue,
155 | SelectTrigger,
156 | SelectContent,
157 | SelectLabel,
158 | SelectItem,
159 | SelectSeparator,
160 | SelectScrollUpButton,
161 | SelectScrollDownButton,
162 | }
163 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SliderPrimitive from "@radix-ui/react-slider"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Slider = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
19 |
20 |
21 |
22 |
23 | ))
24 | Slider.displayName = SliderPrimitive.Root.displayName
25 |
26 | export { Slider }
27 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Cross2Icon } from "@radix-ui/react-icons"
3 | import * as ToastPrimitives from "@radix-ui/react-toast"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "@/components/ui/toast"
9 | import { useToast } from "@/components/ui/use-toast"
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title}}
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ))
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
29 |
--------------------------------------------------------------------------------
/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "@/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/src/hooks/use-mutation-observer.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | export const useMutationObserver = (
4 | ref: React.MutableRefObject,
5 | callback: MutationCallback,
6 | options = {
7 | attributes: true,
8 | characterData: true,
9 | childList: true,
10 | subtree: true,
11 | }
12 | ) => {
13 | React.useEffect(() => {
14 | if (ref.current) {
15 | const observer = new MutationObserver(callback)
16 | observer.observe(ref.current, options)
17 | return () => observer.disconnect()
18 | }
19 | }, [ref, callback, options])
20 | }
--------------------------------------------------------------------------------
/src/lib/FFmpeg.wasm.ts:
--------------------------------------------------------------------------------
1 | import Bowser from "bowser"
2 | import { FFmpeg } from '@ffmpeg/ffmpeg';
3 | import { fetchFile, toBlobURL } from '@ffmpeg/util';
4 | import { useState, useEffect, useRef, useCallback } from 'react';
5 | import { CommandPartsType } from './utils';
6 | import { FileData } from 'node_modules/@ffmpeg/ffmpeg/dist/esm/types';
7 |
8 | interface FFmpegInstance {
9 | load: () => Promise;
10 | transcode: (file: File, commandParts: CommandPartsType) => Promise;
11 | isLoaded: boolean;
12 | isDoing: boolean;
13 | isLoading: boolean;
14 | progress: number;
15 | transcodedTime: number;
16 | logs: string[];
17 | error: Error | null;
18 | openMT:boolean
19 | }
20 |
21 | export function useFFmpeg(): FFmpegInstance {
22 | const ffmpegRef = useRef(null);
23 | const [isLoaded, setIsLoaded] = useState(false);
24 | const [isLoading, setIsLoading] = useState(false);
25 | const [isDoing, setIsDoing] = useState(false);
26 | const [error, setError] = useState(null);
27 | const [progress, setProgress] = useState(0)
28 | const [transcodedTime, setTranscodedTime] = useState(0)
29 | const [openMT, serOpenMT] = useState(false)
30 | // const [logs, setLogs] = useState([]);
31 |
32 | const { logs, updateLogs } = useLogManager()
33 | function useLogManager(maxLogs = 5) {
34 | const [logs, setLogs] = useState([]);
35 |
36 | const updateLogs = useCallback((message: string) => {
37 | setLogs(prevLogs => {
38 | const newLogs = [...prevLogs, message];
39 | return newLogs.length > maxLogs ? newLogs.slice(-maxLogs) : newLogs;
40 | });
41 | }, [maxLogs]);
42 |
43 | return { logs, updateLogs };
44 | }
45 |
46 | const load = useCallback(async () => {
47 | console.log('1. Load function called');
48 | if (isLoaded || isLoading || error) {
49 | console.log('2. Already loaded or loading');
50 | return;
51 | }
52 | console.log('3. Starting load process');
53 | setIsLoading(true);
54 | setError(null);
55 |
56 | try {
57 | if (!ffmpegRef.current) {
58 | console.log('4. Creating FFmpeg instance');
59 | ffmpegRef.current = new FFmpeg();
60 | console.log('5. FFmpeg instance created');
61 | }
62 |
63 | ffmpegRef.current.on('log', ({ message }) => {
64 | updateLogs(message)
65 | // console.log('FFmpeg log:', message);
66 | });
67 | console.log('6. Log listener added');
68 |
69 | ffmpegRef.current.on('progress', ({ progress, time }) => {
70 | setProgress(() => progress * 100)
71 | setTranscodedTime(() => time / 1000000)
72 | });
73 |
74 | // 基于chromium 的浏览器目前不支持 多线程 (不知道为什么, 可以在这里看到官方的 playgroud 是定义了两个CDN 常量 https://github.com/ffmpegwasm/ffmpeg.wasm/blob/main/apps/website/src/components/Playground/const.ts)
75 | const browser = Bowser.getParser(window.navigator.userAgent);
76 | let OpenMT = false
77 | if (browser.getBrowser().name === 'Firefox') {
78 | OpenMT = true;
79 | }
80 | serOpenMT(OpenMT)
81 | // const baseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm';
82 | // const baseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.6/dist/esm'; // jsdelivr 好像开启了必须要的header设定项,否则由于安全策略(同源),只能自己server这个包
83 | const CORE_URL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm'
84 | const CORE_MT_URL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.6/dist/esm'
85 | const baseURL = OpenMT ? CORE_MT_URL : CORE_URL;
86 |
87 | // console.log('7. Fetching core URL');
88 | // const coreURL = await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript');
89 | // console.log('8. Core URL fetched');
90 |
91 | // console.log('9. Fetching WASM URL');
92 | // const wasmURL = await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm');
93 | // console.log('10. WASM URL fetched');
94 |
95 | // console.log('11. Starting FFmpeg load');
96 | // await ffmpegRef.current.load({ coreURL, wasmURL });
97 | await ffmpegRef.current.load({
98 | coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
99 | wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
100 | ...(OpenMT ? {
101 | workerURL: await toBlobURL(
102 | `${baseURL}/ffmpeg-core.worker.js`,
103 | "text/javascript"
104 | ),
105 | } : {})
106 |
107 | });
108 | console.log('OpenMT: 是否开启多线程?', OpenMT)
109 |
110 | console.log('12. FFmpeg loaded successfully');
111 |
112 | setIsLoaded(true);
113 | console.log('13. State updated');
114 | } catch (err) {
115 | console.error('Error in FFmpeg load process:', err);
116 | setError(err instanceof Error ? err : new Error('Failed to load FFmpeg'));
117 | } finally {
118 | console.log('14. Load process completed');
119 | setIsLoading(false);
120 | }
121 | }, [isLoaded, isLoading]);
122 |
123 | const transcode = useCallback(async (file: File, commandParts: CommandPartsType): Promise => {
124 | if (!ffmpegRef.current || !isLoaded) throw new Error('FFmpeg is not loaded');
125 |
126 | try {
127 | await ffmpegRef.current.writeFile(commandParts.input, await fetchFile(file));
128 | console.log('commandParts.executeParts', commandParts.executeParts)
129 | setIsDoing(true)
130 |
131 | // await ffmpegRef.current.exec(["-i","input.mp4","output.mp4"]);
132 | await ffmpegRef.current.exec(commandParts.executeParts);
133 | const result = await ffmpegRef.current.readFile(commandParts.output);
134 | downloadData(result, commandParts.output)
135 | } catch (err: any) {
136 | throw new Error(`Transcoding failed: ${err.message}`);
137 | } finally {
138 | setIsDoing(false)
139 | }
140 | }, [isLoaded]);
141 |
142 |
143 |
144 | useEffect(() => {
145 | load();
146 | }, [load]);
147 |
148 | return {
149 | load,
150 | transcode,
151 | isLoaded,
152 | isLoading,
153 | isDoing,
154 | progress,
155 | transcodedTime,
156 | logs,
157 | error,
158 | openMT
159 | };
160 | }
161 |
162 |
163 | const downloadData = (file: FileData, filename: string) => {
164 | // Create link and download
165 | const a = document.createElement('a');
166 | document.head.appendChild(a);
167 | a.download = filename
168 | a.href = URL.createObjectURL(
169 | new Blob([file])
170 | )
171 | a.click();
172 | }
173 |
--------------------------------------------------------------------------------
/src/lib/ffmpeg-js.bak:
--------------------------------------------------------------------------------
1 |
2 | export const useFFmpeg = () => {
3 | const ffmpeg = new FFmpeg();
4 | ffmpeg.whenReady(async () => {
5 | await ffmpeg.exec(['-help']);
6 | });
7 | }
8 |
9 |
10 |
11 | const readFile = async (file: File) => {
12 | return new Promise((resolve) => {
13 | // setting up the reader
14 | const reader = new FileReader();
15 | reader.readAsArrayBuffer(file);
16 |
17 | // read file
18 | reader.onload = readerEvent => {
19 | const content = readerEvent?.target?.result;
20 | if (!content || typeof content == "string") {
21 | resolve(undefined);
22 | } else {
23 | resolve(new Blob([new Uint8Array(content)], { type: file.type }));
24 | }
25 | }
26 | })
27 | }
28 |
29 | const downloadData = (file: Uint8Array, filename: string) => {
30 | // Create link and download
31 | const a = document.createElement('a');
32 | document.head.appendChild(a);
33 | a.download = filename
34 | a.href = URL.createObjectURL(
35 | new Blob([file])
36 | )
37 | a.click();
38 | }
39 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 |
9 | export function secondsToTime(duration: number): string {
10 | const totalSeconds = Math.floor(duration);
11 | const hours = Math.floor(totalSeconds / 3600);
12 | const minutes = Math.floor((totalSeconds % 3600) / 60);
13 | const seconds = totalSeconds % 60;
14 |
15 | const hoursStr = hours.toString().padStart(2, '0');
16 | const minutesStr = minutes.toString().padStart(2, '0');
17 | const secondsStr = seconds.toString().padStart(2, '0');
18 |
19 | return `${hoursStr}:${minutesStr}:${secondsStr}`;
20 | }
21 |
22 | export type GenerateGMParams = {
23 | bitrate: string,
24 | compression: number,
25 | fps: number,
26 | loop: number,
27 | pix_fmt?: string,
28 | select: number,
29 | width: number,
30 | filetype: string,
31 | input?: string,
32 | output?: string
33 | timeRange: string
34 | }
35 |
36 |
37 | export type CommandPartsType = {
38 | input: string
39 | output: string
40 | executeParts: string[]
41 | }
42 | type GenerateFFmpegCommandType = (params: GenerateGMParams) => { command: string, commandParts: CommandPartsType }
43 | export const generateFFmpegCommand: GenerateFFmpegCommandType = function (params) {
44 | // 设置默认值
45 | const defaults = {
46 | bitrate: "800k",
47 | compression: 23, // 使用更常见的 CRF 值范围
48 | fps: 30,
49 | loop: 0,
50 | pix_fmt: "yuv420p", // 更常用的像素格式
51 | select: 1,
52 | width: 480,
53 | input: "input.mp4",
54 | output: "output",
55 | filetype: 'mp4'
56 | };
57 |
58 | // 合并默认值和用户提供的参数
59 | const options = { ...defaults, ...params };
60 |
61 | // 构建命令数组
62 | const commandParts = [
63 | "ffmpeg",
64 | ` ${options.timeRange} `,
65 | `-i ${options.input}`,
66 | `-b:v ${options.bitrate}`,
67 | `-crf ${options.compression}`,
68 | `-r ${options.fps}`,
69 | `-loop ${options.loop}`,
70 | `-vf select="'not(mod(n,${options.select}))',scale=${options.width}:-1"`,
71 | `-pix_fmt ${options.pix_fmt}`,
72 | `${options.output}.${options.filetype}`
73 | ];
74 |
75 | const commandStr = commandParts.filter(part => part !== '').join(' ')
76 |
77 | const _execute_parts = commandStr.replaceAll('"','').split(' ').filter(i=>!!i)
78 | _execute_parts.shift()
79 | return {
80 | command: commandStr, commandParts: {
81 | input: options.input,
82 | output: `${options.output}.${options.filetype}`,
83 | executeParts: _execute_parts
84 | }
85 | };
86 | }
87 |
88 |
89 |
90 | export function throttle any>(
91 | func: T,
92 | limit: number
93 | ): (...args: Parameters) => void {
94 | let inThrottle: boolean;
95 | let lastFunc: ReturnType;
96 | let lastRan: number;
97 |
98 | return function (this: any, ...args: Parameters) {
99 | const context = this;
100 |
101 | if (!inThrottle) {
102 | func.apply(context, args);
103 | lastRan = Date.now();
104 | inThrottle = true;
105 | } else {
106 | clearTimeout(lastFunc);
107 | lastFunc = setTimeout(() => {
108 | if (Date.now() - lastRan >= limit) {
109 | func.apply(context, args);
110 | lastRan = Date.now();
111 | }
112 | }, limit - (Date.now() - lastRan));
113 | }
114 | };
115 | }
116 |
117 | export function sanitizeFilename(filename:string) {
118 | // 分离文件名和扩展名
119 | const parts = filename.split('.');
120 | let name = parts.slice(0, -1).join('.');
121 | const extension = parts.length > 1 ? parts[parts.length - 1] : '';
122 |
123 | // 定义正则表达式,匹配合法字符(中文字符、英文字符、数字、中划线、下划线)
124 | const pattern = /[^\u4e00-\u9fa5a-zA-Z0-9-_]/g;
125 |
126 | // 替换非法字符为空字符串
127 | name = name.replace(pattern, '');
128 |
129 | // 拼接合法文件名和扩展名
130 | return extension ? `${name}.${extension}` : name;
131 | }
132 |
133 |
134 | export function get24HourTimeStringSuffix() {
135 | const now = new Date();
136 | const hours = String(now.getHours()).padStart(2, '0'); // 获取小时并补全至两位数
137 | const minutes = String(now.getMinutes()).padStart(2, '0'); // 获取分钟并补全至两位数
138 | const seconds = String(now.getSeconds()).padStart(2, '0'); // 获取秒并补全至两位数
139 | return `_${hours}${minutes}${seconds}`; // 返回格式化后的时间字符串
140 | }
141 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import './styles/index.css'
4 | import { RouterProvider, router } from '@/routes'
5 |
6 |
7 |
8 | ReactDOM.createRoot(document.getElementById('root')!).render(
9 | //
10 |
11 | // ,
12 | )
13 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import { useRouteError } from "react-router-dom";
2 |
3 | export default function ErrorPage() {
4 | const error = useRouteError();
5 | console.error(error);
6 |
7 | return (
8 |
9 |
Oops!
10 |
Sorry, an unexpected error has occurred.
11 |
12 | {error.statusText || error.message}
13 |
14 |
15 | );
16 | }
--------------------------------------------------------------------------------
/src/pages/More.tsx:
--------------------------------------------------------------------------------
1 | import { RocketIcon } from "@radix-ui/react-icons"
2 |
3 | import {
4 | Alert,
5 | AlertDescription,
6 | AlertTitle,
7 | } from "@/components/ui/alert"
8 |
9 | export default function More() {
10 | return (
11 |
12 |
13 | More ?
14 |
15 | Maybe more later ~ If needed.
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/pages/VideoToX.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { useForm } from "react-hook-form";
3 | import { z } from "zod";
4 | import BiteRateSelector from "@/components/bit-rate-selector";
5 | import ColorSpaceSelector from "@/components/color-space-selector";
6 | import Dropzone from "@/components/DropZone";
7 | import FileTypeSelector from "@/components/file-type-selector";
8 | import { HugeiconsLoading03, MingcuteCloseFill } from "@/components/icons";
9 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
10 | import { Button } from "@/components/ui/button";
11 | import {
12 | Form,
13 | FormControl,
14 | FormDescription,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage,
19 | } from "@/components/ui/form";
20 | import { Input } from "@/components/ui/input";
21 | import { Progress } from "@/components/ui/progress";
22 | import { Slider } from "@/components/ui/slider";
23 | import {
24 | Tooltip,
25 | TooltipContent,
26 | TooltipProvider,
27 | TooltipTrigger,
28 | } from "@/components/ui/tooltip";
29 | import { useToast } from "@/components/ui/use-toast";
30 | import VideoClipper from "@/components/VideoClipper";
31 | import { useFFmpeg } from "@/lib/FFmpeg.wasm";
32 | import {
33 | CommandPartsType,
34 | generateFFmpegCommand,
35 | get24HourTimeStringSuffix,
36 | sanitizeFilename,
37 | } from "@/lib/utils";
38 | import {
39 | ExclamationTriangleIcon,
40 | QuestionMarkCircledIcon,
41 | } from "@radix-ui/react-icons";
42 | import { Separator } from "@radix-ui/react-separator";
43 | import { useEffect, useState } from "react";
44 |
45 | const formSchema = z.object({
46 | fps: z.coerce
47 | .number()
48 | .lte(120, {
49 | message: "帧率必须小于120.",
50 | })
51 | .gte(10, { message: "帧率必须大于10." }),
52 | width: z.coerce
53 | .number()
54 | .lte(5000, {
55 | message: "宽度必须小于5000.",
56 | })
57 | .gte(1, { message: "宽度必须大于0." }),
58 | loop: z.coerce.number().gte(0, { message: "循环次数必须大于0." }),
59 | bitrate: z.string().min(0, { message: "比特率选择不可为空." }),
60 | compression: z.coerce
61 | .number()
62 | .lte(100, {
63 | message: "压缩级别必须小于100.",
64 | })
65 | .gte(0, { message: "压缩级别必须大于0." }),
66 | output: z
67 | .string()
68 | .min(1, { message: "文件名不能为空" })
69 | .max(50, { message: "文件名长度不能超过50个字符" })
70 | .regex(/^[a-zA-Z0-9_-]+$/, {
71 | message: "文件名只能包含字母、数字、下划线和连字符",
72 | })
73 | .refine((name) => !name.startsWith("-"), {
74 | message: "文件名不能以连字符开头",
75 | })
76 | .refine((name) => !name.endsWith("-"), {
77 | message: "文件名不能以连字符结尾",
78 | }),
79 | select: z.coerce.number(),
80 | pix_fmt: z.string().optional(),
81 | filetype: z.string(),
82 | });
83 |
84 | export default function ProfileForm() {
85 | const [command, setCommand] = useState("");
86 | const [timeRange, setTimeRange] = useState("");
87 | const [files, setFiles] = useState([]);
88 | const [videoSrc, setVideoSrc] = useState("");
89 | const [output, setOutput] = useState("output");
90 | // 1. Define your form.
91 | const form = useForm>({
92 | resolver: zodResolver(formSchema),
93 | defaultValues: {
94 | fps: 30,
95 | width: 480,
96 | loop: 0,
97 | bitrate: "800k",
98 | compression: 10,
99 | select: 1,
100 | pix_fmt: "yuv420p",
101 | filetype: "mp4",
102 | output: output,
103 | },
104 | });
105 | useEffect(() => {
106 | form.setValue("output", output);
107 | }, [output, form]);
108 |
109 | const { toast } = useToast();
110 | const ffmpeg = useFFmpeg();
111 |
112 | const handleVideoClipperChange = (startPoint: string, endPoint: string) => {
113 | if (startPoint && endPoint) {
114 | setTimeRange(`-ss ${startPoint} -t ${endPoint}`);
115 | } else {
116 | setTimeRange("");
117 | }
118 | };
119 |
120 | const handleDropzoneChange = (files: File[]) => {
121 | setFiles(() => files);
122 | setVideoSrc(URL.createObjectURL(files[0]));
123 | setOutput(sanitizeFilename(files[0].name).split(".")[0]);
124 | };
125 | const handleTranscode = async (
126 | file: File,
127 | commandParts: CommandPartsType
128 | ) => {
129 | if (!ffmpeg.isLoaded) {
130 | console.log("FFmpeg is not loaded yet");
131 | return;
132 | }
133 |
134 | try {
135 | await ffmpeg.transcode(file, commandParts);
136 | console.log("Transcoding completed");
137 | } catch (error) {
138 | console.error("Transcoding failed:", error);
139 | }
140 | };
141 |
142 | async function onSubmit(
143 | values: z.infer,
144 | onlyGenerateCommand?: boolean
145 | ) {
146 | const { command, commandParts } = generateFFmpegCommand({
147 | ...values,
148 | timeRange,
149 | input: sanitizeFilename(files[0].name),
150 | output: values.output + get24HourTimeStringSuffix(),
151 | });
152 | setCommand(command);
153 | if (onlyGenerateCommand) return;
154 |
155 | if (!onlyGenerateCommand && files.length == 0) {
156 | toast({
157 | title: "未检测到文件上传,无法执行转换",
158 | description: "File is not uploaded, Please try again.",
159 | });
160 | return;
161 | }
162 | await handleTranscode(files[0], commandParts);
163 | }
164 |
165 | return (
166 | <>
167 |
168 |
169 |
170 | 建议在 FireFox 中使用,以开启多线程支持
171 |
172 |
173 | It is recommended to use Firefox to enable multi-threading
174 |
175 |
176 | {files[0] ? (
177 |
178 |
184 |
185 | ) : (
186 |
191 | )}
192 |
193 |
194 |
195 |
355 |
356 |
357 |
364 |
374 |
375 |
376 | FFmpeg.wasm{" "}
377 | {ffmpeg.isLoading ? (
378 |
379 | ) : (
380 |
381 | )}
382 |
383 |
384 |
385 | 多线程支持{" "}
386 | {!ffmpeg.openMT ? (
387 |
388 | ) : (
389 |
390 | )}
391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 | 多线程将会带来更快的处理速度
399 |
400 |
401 | 本项目FFmpeg 核心使用的是{" "}
402 |
407 | FFmpeg.wasm
408 |
409 | . 目前多线程似乎不支持基于 Chromium 的浏览器。 经测试,FireFox
410 | 可以很好的支持。 因此在 FireFox 浏览器中多线程能力是开启的。
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 | {ffmpeg.progress !== 0 && (
419 | <>
420 | {" "}
421 |
{" "}
422 |
423 | {Math.floor(ffmpeg.transcodedTime)} s
424 |
425 | >
426 | )}
427 |
428 |
429 | {ffmpeg.error && (
430 |
431 |
432 | Error
433 | {ffmpeg.error?.message}
434 |
435 | )}
436 |
437 | {ffmpeg.logs.length !== 0 && (
438 |
439 | Logs
440 |
441 | {ffmpeg.logs.map((i, index) => {
442 | return (
443 |
444 | {i}
445 |
446 | );
447 | })}
448 |
449 |
450 | )}
451 |
452 | {command && (
453 |
454 | 生成指令:
455 |
456 | {command}
457 |
458 |
459 | )}
460 |
461 | {/*
462 |
463 | No Safari Support !
464 |
465 | Sorry that there is might no support for some browser like safari right now.
466 |
467 | */}
468 | >
469 | );
470 | }
471 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createBrowserRouter,
3 | createHashRouter,
4 | Navigate,
5 | RouterProvider,
6 | } from "react-router-dom";
7 | import ErrorPage from "@/pages/404";
8 | import Layout from "@/components/Layout";
9 | import VideoToX from "@/pages/VideoToX";
10 | import MORE from "@/pages/More";
11 | import { ROUTES } from "./typings";
12 |
13 | // const router: ReturnType = createBrowserRouter([
14 | const router: ReturnType = createHashRouter([
15 | {
16 | path: "/",
17 | element: ,
18 | errorElement: ,
19 |
20 | children: [
21 | {
22 | index: true, // This marks the default child route
23 | element: ,
24 | },
25 | {
26 | path: ROUTES.VIDEO_TO_X,
27 | element: ,
28 | },
29 | {
30 | path: ROUTES.MORE,
31 | element: ,
32 | },
33 | ],
34 | },
35 | ]);
36 | export { RouterProvider, router };
37 |
--------------------------------------------------------------------------------
/src/routes/typings.ts:
--------------------------------------------------------------------------------
1 | export enum ROUTES {
2 | VIDEO_TO_X = "video-to-x",
3 | MORE = "more",
4 | }
--------------------------------------------------------------------------------
/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 72.22% 50.59%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 5% 64.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark,:root[class~="dark"] {
35 | --background: 240 10% 3.9%;
36 | --foreground: 0 0% 98%;
37 | --card: 240 10% 3.9%;
38 | --card-foreground: 0 0% 98%;
39 | --popover: 240 10% 3.9%;
40 | --popover-foreground: 0 0% 98%;
41 | --primary: 0 0% 98%;
42 | --primary-foreground: 240 5.9% 10%;
43 | --secondary: 240 3.7% 15.9%;
44 | --secondary-foreground: 0 0% 98%;
45 | --muted: 240 3.7% 15.9%;
46 | --muted-foreground: 240 5% 64.9%;
47 | --accent: 240 3.7% 15.9%;
48 | --accent-foreground: 0 0% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 0 85.7% 97.3%;
51 | --border: 240 3.7% 15.9%;
52 | --input: 240 3.7% 15.9%;
53 | --ring: 240 4.9% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | html,body {
67 | @apply bg-background text-foreground;
68 | font-feature-settings: "rlig" 1, "calt" 1;
69 | }
70 | }
71 |
72 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare FFmpeg
--------------------------------------------------------------------------------
/styles/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 10% 3.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 240 10% 3.9%;
36 | --foreground: 0 0% 98%;
37 | --card: 240 10% 3.9%;
38 | --card-foreground: 0 0% 98%;
39 | --popover: 240 10% 3.9%;
40 | --popover-foreground: 0 0% 98%;
41 | --primary: 0 0% 98%;
42 | --primary-foreground: 240 5.9% 10%;
43 | --secondary: 240 3.7% 15.9%;
44 | --secondary-foreground: 0 0% 98%;
45 | --muted: 240 3.7% 15.9%;
46 | --muted-foreground: 240 5% 64.9%;
47 | --accent: 240 3.7% 15.9%;
48 | --accent-foreground: 0 0% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 0 0% 98%;
51 | --border: 240 3.7% 15.9%;
52 | --input: 240 3.7% 15.9%;
53 | --ring: 240 4.9% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | }
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2021", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "moduleDetection": "force",
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 |
20 | /* Linting */
21 | "strict": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noFallthroughCasesInSwitch": true,
25 |
26 | "baseUrl": ".",
27 | "paths": {
28 | "@/*": ["./src/*"]
29 | }
30 | },
31 | "include": ["src"]
32 | }
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ],
11 | "compilerOptions": {
12 | "baseUrl": ".",
13 | "paths": {
14 | "@/*": ["./src/*"]
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "skipLibCheck": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "noEmit": true
11 | },
12 | "include": ["vite.config.ts","tailwind.config.js"]
13 | }
14 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "headers": [
3 | {
4 | "source": "/",
5 | "headers": [
6 | {
7 | "key": "Cross-Origin-Embedder-Policy",
8 | "value": "require-corp"
9 | },
10 | {
11 | "key": "Cross-Origin-Opener-Policy",
12 | "value": "same-origin"
13 | }
14 | ]
15 | },
16 | {
17 | "source": "/assets/(.*)",
18 | "headers": [
19 | {
20 | "key": "Cross-Origin-Embedder-Policy",
21 | "value": "require-corp"
22 | },
23 | {
24 | "key": "Cross-Origin-Opener-Policy",
25 | "value": "same-origin"
26 | }
27 | ]
28 | }
29 | ]
30 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url'
2 | import { defineConfig } from 'vite'
3 | import react from '@vitejs/plugin-react'
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | base: './',
9 | resolve: {
10 | alias: {
11 | '@': fileURLToPath(new URL('./src', import.meta.url))
12 | }
13 | },
14 | optimizeDeps: { exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"], },
15 | server: {
16 | headers: {
17 | 'Cross-Origin-Embedder-Policy': 'require-corp',
18 | 'Cross-Origin-Opener-Policy': 'same-origin',
19 | },
20 | }
21 | })
22 |
--------------------------------------------------------------------------------