使用vite打包react 组件- 视频抽帧

1,308 阅读1分钟

使用vite打包react 组件

vite 文档

vite to learn

新建项目


npm create vite@latest my-app --template react

npm install

npm run dev


开发组件

image.png

  1. src 下新建components文件夹
  2. 代码如下
// index.tsx
import ShotVideo from "./video";
import { seekVideo } from "./seekVideo";

export { ShotVideo as VideoShotScreen, seekVideo as VideoSeeksScreen };
export type { ScrollShotUrls, ShotVideoProps } from './video'
// seekVideo.ts
export function seekVideo(
  video: HTMLVideoElement,
  canvas: HTMLCanvasElement,
  time: number,
  drawWidth: number,
  drawHeight: number,
  qutZero?: boolean,
) {
  let resolve: (arg0: { time: number; img: string }) => void;
  const onSeeked = () => {
    video?.removeEventListener('seeked', onSeeked);
    const context = canvas.getContext('2d');
    context?.drawImage(video, 0, 0, drawWidth, drawHeight);
    const img = canvas.toDataURL();
    resolve?.({ time, img });
  };
  return new Promise((r) => {
    resolve = r;
    video?.addEventListener('seeked', onSeeked);
    let t = time;
    // 很多视频起始帧是无意义的黑屏,默认取 0.1s 开始
    if ((t <= 0 && qutZero) || isNaN(t)) {
      t = 0.1;
    }
    // 末尾的帧向下取整到小数点后 1 位,否则有可能指定 currentTime 无效
    if (video?.duration - t < 0.1) {
      t = Math.floor(t);
    }
    video.currentTime = parseInt(`${t * 10}`, 10) / 10;
  });
}

// video.tsx
import { useEffect, useRef, useState } from 'react'
import styles from './play.module.less'
import { seekVideo } from './seekVideo';


export interface ScrollShotUrls {
  time: number;
  img: string;
  toobarWidth?: number;
  toobarHeight?: number;
  shotReady?: boolean;
  duration: number;
}

const defaultSlideWidth = 750;
const defaultSlideHeight = 44;
export interface ShotVideoProps {
  getUrls?: (urls: ScrollShotUrls[]) => void;
  slideWidth?: number;
  slideHeight?: number;
  file?: File
}

const ShotVideo: React.FC<ShotVideoProps> = ({ slideHeight = defaultSlideHeight, slideWidth = defaultSlideWidth, getUrls, file: uploadFile }) => {
  const videoRef = useRef<HTMLVideoElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const [file, setFile] = useState<File | null>(null);
  const [urls, setUrls] = useState<ScrollShotUrls[]>([]);
  const [videoSrc, setVideoSrc] = useState('');
  const [error, setError] = useState(false);

  useEffect(() => {
    if (uploadFile) {
      setFile(uploadFile);
    }
  }, [uploadFile])

  const handleOnChange = (e: any) => {
    const file = e.target.files[0];
    setFile(file);
  }

  useEffect(() => {
    if (error) {
      console.log('error');
    }
  }, [error]);

  useEffect(() => {
    startShot();
  }, [file, canvasRef?.current, videoRef?.current, slideWidth, slideWidth])

  const startShot = () => {
    if (!file || !canvasRef?.current || !videoRef?.current) {
      return;
    }
    const video: HTMLVideoElement = videoRef?.current as HTMLVideoElement;
    const url = window.URL.createObjectURL(file);
    setVideoSrc(url);
    video.src = url;

    const canvas: HTMLCanvasElement = canvasRef?.current as HTMLCanvasElement;

    video.addEventListener('loadeddata', async () => {
      const { duration, videoWidth, videoHeight } = videoRef.current as HTMLVideoElement;
      if (!duration || !videoWidth || !videoHeight) {
        setError(true);
      }
      canvas.width = videoWidth;
      canvas.height = videoHeight;

      const toobarHeight = slideHeight;
      const toobarWidth = (toobarHeight * videoWidth) / videoHeight;
      const toobarCount = Math.ceil(slideWidth / toobarWidth);
      const loopcount = duration / (toobarCount - 1);

      const drawWidth = toobarWidth * 2;
      const drawHeight = toobarHeight * 2;

      video.width = drawWidth;
      video.height = drawHeight;

      video.style.height = `${drawWidth}px`;
      video.style.width = `${drawHeight}px`;

      canvas.width = drawWidth;
      canvas.height = drawHeight;
      const shotUrl: ScrollShotUrls[] = [];
      for (let i = 0; i < toobarCount; i++) {
        try {
          const temp = (await seekVideo(video, canvas, Math.min(loopcount * i, duration), drawWidth, drawHeight, true)) as {
            time: number;
            img: string;
          };
          shotUrl.push({ ...temp, toobarWidth, toobarHeight, duration });
        } catch (error) {
          console.error(error);
        }
      }
      setUrls(shotUrl)
      if (getUrls && typeof getUrls === 'function') {
        getUrls(shotUrl);
      }
      video.addEventListener('error', () => {
        setError(true);
      });
    });
  }

  return (
    <>
      {
        uploadFile ? null :
          <div>
            <div><input type="file" onChange={handleOnChange} />上传</div>
            <canvas ref={canvasRef} />
            <video className={styles['playContainer']} ref={videoRef} src={videoSrc} />
            {
              urls?.map((e, i) => {
                return <img src={e?.img} key={i} />
              })
            }
          </div>
      }
    </>
  )
}

export default ShotVideo

打包

  1. 设置vite.config.ts
// npm install @types/node --save-dev
// npm i @rollup/plugin-typescript

import { readFileSync } from 'fs'
import path from 'path'
import { defineConfig } from 'vite'

import typescript from '@rollup/plugin-typescript'


import react from '@vitejs/plugin-react'

const packageJson = JSON.parse(
  readFileSync('./package.json', { encoding: 'utf-8' }),
)
const globals = {
  ...(packageJson?.dependencies || {}),
}

function resolve(str: string) {
  return path.resolve(__dirname, str)
}

export default defineConfig({
  plugins: [react(),
  typescript({
    target: 'es5',
    rootDir: resolve('src/components'),
    declaration: true,
    declarationDir: resolve('dist'),
    exclude: resolve('node_modules/**'),
    allowSyntheticDefaultImports: true,
  }),
  ],
  build: {
    // 输出文件夹
    outDir: 'dist',
    lib: {
      // 组件库源码的入口文件
      entry: resolve('src/components/index.tsx'),
      // 组件库名称
      name: 'demo',
      // 文件名称, 打包结果举例: suemor.cjs
      fileName: 'suemor',
      // 打包格式
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      //排除不相关的依赖
      external: ['react', 'react-dom', ...Object.keys(globals)],
    },
  },
})

发布包

设置packages.json

{
  "name": "videoscreen",
  "private": false,
  "version": "0.0.3",
  "type": "module",
  "main": "./dist/suemor.cjs",
  "module": "./dist/suemor.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/suemor.cjs",
      "import": "./dist/suemor.js"
    },
    "./style": "./dist/style.css"
  },
  "publishConfig": {
    "access": "public"
  },
  "files": [
    "dist"
  ],
...
}

发布

npm adduser

npm publish

使用

// npm install videoscreen

import  {VideoShotScreen} from 'videoscreen';

<VideoShotScreen/>

lint

npm install eslint --save-dev
npx eslint --init

npm install vite-plugin-eslint --save-dev

import eslint from 'vite-plugin-eslint';
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), eslint()],
})


//.eslintrc.cjs

npm install prettier  --save-dev

// .prettierrc.js
module.exports = {
  printWidth: 80,
  tabWidth: 2,
  useTabs: false,
  singleQuote: true,
  semi:false,
  trailingComma: "none",
  bracketSpacing: true
}

 npm install eslint-config-prettier eslint-plugin-prettier --save-dev
 
 
 // 设置配置 .eslintrc.cjs  https://github.com/prettier/eslint-plugin-prettier#recommended-configuration

image.png

// .eslintrc.cjs
/**
 * https://github.com/prettier/eslint-plugin-prettier#recommended-configuration
 */
module.exports = {
	"env": {
		"browser": true,
		"es2021": true,
		"node": true
	},
	"extends": [
		"eslint:recommended",
		"plugin:@typescript-eslint/recommended",
		"plugin:react/recommended",
		"plugin:prettier/recommended",
		'plugin:react/jsx-runtime'
	],
	settings: {
		react: {
			version: 'detect'
		}
	},
	"overrides": [
		{
			"env": {
				"node": true
			},
			"files": [
				".eslintrc.{js,cjs}"
			],
			"parserOptions": {
				"sourceType": "script"
			}
		}
	],
	"parser": "@typescript-eslint/parser",
	"parserOptions": {
		"ecmaVersion": "latest",
		"sourceType": "module"
	},
	"plugins": [
		"@typescript-eslint",
		"react",
		"prettier"
	],
	"rules": {
		"prettier/prettier": "error",
		"arrow-body-style": "off",
		"prefer-arrow-callback": "off",
		"react/prop-types": "off"
	}
};

package.json 添加
"lint": "eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./ --report-unused-disable-directives --max-warnings 0",


packages.json

"type": "commonjs",
// packages.json
{
  "name": "videoscreen",
  "private": false,
  "version": "0.0.3",
  "type": "commonjs",
  "main": "./dist/suemor.cjs",
  "module": "./dist/suemor.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/suemor.cjs",
      "import": "./dist/suemor.js"
    },
    "./style": "./dist/style.css"
  },
  "publishConfig": {
    "access": "public"
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build ",
    "lint": "eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./ --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "@rollup/plugin-typescript": "^11.1.2",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.4.9",
    "@types/react": "^18.2.15",
    "@types/react-dom": "^18.2.7",
    "@typescript-eslint/eslint-plugin": "^6.3.0",
    "@typescript-eslint/parser": "^6.3.0",
    "@vitejs/plugin-react": "^4.0.3",
    "eslint": "^8.47.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-react": "^7.33.1",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.3",
    "less": "^4.2.0",
    "prettier": "^3.0.1",
    "typescript": "^5.0.2",
    "vite": "^4.4.5",
    "vite-plugin-eslint": "^1.8.1"
  }
}

Husky + lint-staged

npm install husky --save-dev

npm pkg set scripts.prepare="husky install"

npm run prepare

npx husky add .husky/pre-commit "npm run lint"

npm install lint-staged  --save-dev

npm install @commitlint/cli @commitlint/config-conventional --save-dev

npx husky add .husky/commit-msg "npx --no-install commitlint -e $HUSKY_GIT_PARAMS"

参考文档

juejin.cn/post/712361…