视频截图- shot

50 阅读2分钟

import moment from 'moment';
import { useEffect, useRef } from 'react';
import { ScrollShotUrls, seekVideo } from '..';

export enum ShotStatus {
  INIT,
  SUCCESS,
  FAIL,
  INITFAIL,

}
export interface VideoShotProps {
  file?: File;
  shotUrlName?: string;
  shotUrl?: ScrollShotUrls[];
  setFieldValue?: (field: string, value: any, shouldValidate?: boolean | undefined) => void;
  getShotStatus?: (status: ShotStatus) => void;
}

export const VideoShot: React.FC<VideoShotProps> = (props) => {
  const {
    setFieldValue,
    file,
    shotUrlName,
    shotUrl,
    getShotStatus
  } = props;

  const videoRef = useRef<HTMLVideoElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  const slideWidth = 750;
  const slideHeight = 44;

  useEffect(() => {
    if (!file || shotUrl?.length !== 0) {
      return;
    }

    console.log('start', file);

    videoRef.current = document.createElement('video');
    canvasRef.current = document.createElement('canvas');

    const video: HTMLVideoElement | null = videoRef.current as HTMLVideoElement;
    let canvas: HTMLCanvasElement | null = canvasRef.current as HTMLCanvasElement;

    const url = window.URL.createObjectURL(file);
    video.src = url;

    if (!(typeof video.canPlayType === 'function')) {
      handleChangeStatus(ShotStatus.INITFAIL);
    }

    console.log('startTime:', moment());

    video?.addEventListener('loadeddata', async () => {
      const { duration, videoWidth, videoHeight } = video;
      if (!duration || !videoWidth || !videoHeight || !canvas) {
        console.error('视频文件存在问题');
        handleChangeStatus(ShotStatus.INITFAIL);
        return;
      }

      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);
        }
      }

      console.log('endTime: ', moment())

      if (setFieldValue && shotUrlName) {
        setFieldValue(shotUrlName, shotUrl);
        handleChangeStatus(ShotStatus.SUCCESS);
        canvas = null;
      }
    });

    video.addEventListener('error', (err) => {
      console.error(err, 'listenError');
      handleChangeStatus(ShotStatus.INITFAIL);
    });
  }, [file]);

  const handleChangeStatus = (status: ShotStatus) => {
    if (getShotStatus && typeof getShotStatus === 'function') {
      getShotStatus(status);
    }
  }

  return null;
};
import { useEffect, useRef, useState } from 'react';
import { SliderBar } from './sliderBar';
import styles from './index.less';
import ImageLoading from '@/pages/upload/ImageLoading';
import { ShotStatus } from './shot/shot';
import { XtButton } from '@shared/components/XtButton';
import CcLoading from '@/components/core/CcLoading';
export interface VideoScreenShotProps {
  file?: File;
  shotUrl?: ScrollShotUrls[];
  setFieldValue?: (field: string, value: any, shouldValidate?: boolean | undefined) => void;
  shotUrlName?: string;
  shotStatus?: ShotStatus;
  onSave: (url: string) => void;
}
export interface ScrollShotUrls {
  time: number;
  img: string;
  toobarWidth?: number;
  toobarHeight?: number;
  shotReady?: boolean;
  duration: number;
}

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;
  });
}

export const VideoScreenShot: React.FC<VideoScreenShotProps> = (props) => {
  const { file, shotUrl: propsShotUrls = [], shotStatus, onSave } = props;
  const videoRef = useRef<HTMLVideoElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  const [error, setError] = useState(false);

  const [videoSrc, setVideoSrc] = useState('');
  const [shotUrls, setShotUrls] = useState<ScrollShotUrls[]>([]);

  const [shotIng, setShotIng] = useState(false);

  useEffect(() => {
    setShotUrls(propsShotUrls);
  }, [propsShotUrls]);

  const [sliderValue, setSliderValue] = useState(0);

  const [shotInfo, setShotInfo] = useState({
    duration: 0,
  });

  useEffect(() => {
    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);
      }
      setShotInfo({
        duration,
      });
      canvas.width = videoWidth;
      canvas.height = videoHeight;

      video.addEventListener('error', () => {
        setError(true);
      });
    });
  }, [file, videoRef?.current, canvasRef?.current]);

  const handleShotScreen = async (time: number) => {
    setShotIng(true);
    if (videoRef.current && canvasRef.current) {
      const temp = (await seekVideo(
        videoRef.current,
        canvasRef.current,
        time,
        videoRef.current.videoWidth,
        videoRef.current.videoHeight,
      )) as ScrollShotUrls;
      setShotIng(false);
      onSave(temp.img);
    }
  };

  const handleSliderChange = (value: number | number[]) => {
    setSliderValue(value as number);
    if (typeof value === 'number' && videoRef.current) {
      videoRef.current.currentTime = value;
    }
  };

  const handleClick = (e: any) => {
    const sliderImgsLeft = document.getElementById('sliderImgsId')?.getBoundingClientRect().left || 0;
    const clickX = e.clientX - sliderImgsLeft;
    const average = shotInfo.duration / 730;
    handleSliderChange(clickX * average)
  }

  if (shotStatus === ShotStatus.FAIL || shotStatus === ShotStatus.INITFAIL || error) {
    return (
      <div className={styles.loadingMsg}>
        <ImageLoading text="当前视频不支持动态裁剪,建议手工上传封面"></ImageLoading>
      </div>
    );
  }

  if (shotUrls?.length === 0) {
    return (
      <div className={styles.loadingMsg}>
        <ImageLoading text="视频正在加载中,请稍后"></ImageLoading>
      </div>
    );
  }
  return (
    <div className={styles.conatiner}>
      <canvas className={styles.hidden} ref={canvasRef} />
      <video className={styles.playContainer} ref={videoRef} src={videoSrc} />
      <div className={styles.sliderBox}>
        <div className={styles.smallImgs} onClick={handleClick} id='sliderImgsId'>
          <div style={{ display: 'flex' }}>
            {shotUrls?.map((e, i) => (
              <img key={i} src={e?.img} alt="Screenshot" style={{ width: e.toobarWidth, height: e.toobarHeight }} />
            ))}
          </div>
        </div>
        <SliderBar
          range={false}
          step={0.1}
          value={sliderValue}
          min={0.1}
          max={shotInfo.duration}
          onChange={handleSliderChange}
        />
      </div>
      <div className={styles.footer}>
        <XtButton style={{ width: 102, height: 40 }} onClick={() => handleShotScreen(sliderValue)}>
          下一步{shotIng ? <CcLoading /> : null}
        </XtButton>
      </div>
    </div>
  );
};

import 'rc-slider/assets/index.css';
import Slider, { SliderProps } from 'rc-slider';

import styles from './slider.less';
export type SliderBarProps = SliderProps;
const trackStyle = {
  height: 44,
};
export const SliderBar: React.FC<SliderBarProps> = (props) => (
  <div className={styles.sliderContainer}>
    <Slider
      className={styles.ccSlider}
      defaultValue={0}
      min={0}
      trackStyle={trackStyle}
      railStyle={trackStyle}
      {...props}
    />
  </div>
);