| 1 | import { |
| 2 | ForwardedRef, |
| 3 | forwardRef, |
| 4 | memo, |
| 5 | useEffect, |
| 6 | useRef, |
| 7 | useState, |
| 8 | } from 'react'; |
| 9 | import ImgChoose, { |
| 10 | formatImg, |
| 11 | getImgFile, |
| 12 | IImgFile, |
| 13 | } from '@/components/Choose/ImgChoose'; |
| 14 | import styles from './videoCoverSeting.module.scss'; |
| 15 | import { Alert, Button, message, Modal, Slider, Spin } from 'antd'; |
| 16 | import { IVideoFile } from '@/components/Choose/VideoChoose'; |
| 17 | import Cropper from 'cropperjs'; |
| 18 | import 'cropperjs/dist/cropper.css'; |
| 19 | import { ipcGetVideoCover } from '@/icp/tools'; |
| 20 | import { formatSeconds } from '@/utils'; |
| 21 | import { icpSaveFile } from '@/icp/view'; |
| 22 | import { useVideoPageStore } from '@/views/publish/children/videoPage/useVideoPageStore'; |
| 23 | import { useShallow } from 'zustand/react/shallow'; |
| 24 | import { CloseCircleOutlined } from '@ant-design/icons'; |
| 25 | |
| 26 | export interface IVideoCoverSetingRef {} |
| 27 | |
| 28 | export interface IVideoCoverSetingProps { |
| 29 | // 封面选择完成 |
| 30 | onChoosed: (imgFile: IImgFile) => void; |
| 31 | // 当前选择的封面 |
| 32 | value?: IImgFile; |
| 33 | // 需要截帧的视频 |
| 34 | videoFile?: IVideoFile; |
| 35 | // 保存图片的唯一值 |
| 36 | saveImgId: string; |
| 37 | // 关闭按钮点击事件,如果有这个事件就会显示关闭按钮 |
| 38 | onClose?: () => void; |
| 39 | } |
| 40 | |
| 41 | // 保存裁剪的图片 |
| 42 | export const saveCropperImage = ( |
| 43 | filename: string, |
| 44 | file: Blob, |
| 45 | ): Promise<string> => { |
| 46 | return new Promise((resolve) => { |
| 47 | const reader = new FileReader(); |
| 48 | reader.onload = async function (event) { |
| 49 | const arrayBuffer = event.target!.result as ArrayBuffer; |
| 50 | const uint8Array = new Uint8Array(arrayBuffer); |
| 51 | |
| 52 | const imgPath = await icpSaveFile({ |
| 53 | file: uint8Array, |
| 54 | saveDir: '/images/cropper', |
| 55 | filename: filename, |
| 56 | }); |
| 57 | resolve(imgPath); |
| 58 | }; |
| 59 | reader.readAsArrayBuffer(file); |
| 60 | }); |
| 61 | }; |
| 62 | |
| 63 | // 视频封面设置 |
| 64 | const VideoCoverSeting = memo( |
| 65 | forwardRef( |
| 66 | ( |
| 67 | { |
| 68 | onChoosed, |
| 69 | value, |
| 70 | videoFile, |
| 71 | saveImgId, |
| 72 | onClose, |
| 73 | }: IVideoCoverSetingProps, |
| 74 | ref: ForwardedRef<IVideoCoverSetingRef>, |
| 75 | ) => { |
| 76 | const [imgFile, setImgFile] = useState<IImgFile>(); |
| 77 | const [videoCoverSetingModal, setVideoCoverSetingModal] = useState(false); |
| 78 | const cropper = useRef<Cropper>(); |
| 79 | const cropperImg = useRef<HTMLImageElement>(null); |
| 80 | const [videoCoverLoading, setVideoCoverLoading] = useState(false); |
| 81 | const [sliderVal, setSliderVal] = useState(0); |
| 82 | const { operateId } = useVideoPageStore( |
| 83 | useShallow((state) => ({ |
| 84 | operateId: state.operateId, |
| 85 | })), |
| 86 | ); |
| 87 | |
| 88 | useEffect(() => { |
| 89 | if (!videoCoverSetingModal) return; |
| 90 | if (value) { |
| 91 | setImgFile(value); |
| 92 | return; |
| 93 | } |
| 94 | getVideoCover(0); |
| 95 | }, [videoCoverSetingModal]); |
| 96 | |
| 97 | useEffect(() => { |
| 98 | if (!imgFile) return; |
| 99 | initCropper(); |
| 100 | }, [imgFile]); |
| 101 | |
| 102 | /* 获取封面 */ |
| 103 | const getVideoCover = async (n: number) => { |
| 104 | if (!videoFile!.videoPath) return; |
| 105 | setSliderVal(n); |
| 106 | setVideoCoverLoading(true); |
| 107 | const pathVideo = await ipcGetVideoCover( |
| 108 | videoFile!.videoPath!, |
| 109 | formatSeconds(n), |
| 110 | ); |
| 111 | const imgFile = await getImgFile(pathVideo); |
| 112 | setVideoCoverLoading(false); |
| 113 | setImgFile(imgFile); |
| 114 | }; |
| 115 | |
| 116 | const close = () => { |
| 117 | setVideoCoverSetingModal(false); |
| 118 | }; |
| 119 | |
| 120 | // 初始化裁剪工具 |
| 121 | const initCropper = () => { |
| 122 | if (!cropperImg.current) return; |
| 123 | |
| 124 | if (cropper.current) { |
| 125 | cropper.current.destroy(); |
| 126 | cropper.current = undefined; |
| 127 | } |
| 128 | |
| 129 | cropper.current = new Cropper(cropperImg.current!, { |
| 130 | viewMode: 2, |
| 131 | zoomable: false, |
| 132 | minCropBoxWidth: 100, |
| 133 | minCropBoxHeight: 100, |
| 134 | ready() { |
| 135 | cropper.current!.setCropBoxData({ |
| 136 | left: 0, |
| 137 | top: 0, |
| 138 | width: cropperImg.current!.naturalWidth, |
| 139 | height: cropperImg.current!.naturalHeight, |
| 140 | }); |
| 141 | }, |
| 142 | }); |
| 143 | }; |
| 144 | |
| 145 | return ( |
| 146 | <> |
| 147 | <div |
| 148 | className={styles.videoCoverSeting} |
| 149 | onClick={() => { |
| 150 | if (!videoFile) return message.warning('您必须上传一个视频!'); |
| 151 | setVideoCoverSetingModal(true); |
| 152 | }} |
| 153 | > |
| 154 | <div className="videoCoverSeting-img"> |
| 155 | {value && ( |
| 156 | <div className="videoCoverSeting-choosed"> |
| 157 | <img src={value?.imgUrl} /> |
| 158 | {onClose && ( |
| 159 | <CloseCircleOutlined |
| 160 | onClick={(e) => { |
| 161 | e.stopPropagation(); |
| 162 | onClose(); |
| 163 | }} |
| 164 | /> |
| 165 | )} |
| 166 | </div> |
| 167 | )} |
| 168 | </div> |
| 169 | <div className="videoCoverSeting-text">上传图片</div> |
| 170 | </div> |
| 171 | |
| 172 | <Modal |
| 173 | width={600} |
| 174 | title="设置封面" |
| 175 | maskClosable={false} |
| 176 | open={videoCoverSetingModal} |
| 177 | onCancel={close} |
| 178 | onOk={async () => { |
| 179 | const canvas = cropper.current!.getCroppedCanvas(); |
| 180 | canvas.toBlob(async function (blob) { |
| 181 | const imgPath = await saveCropperImage( |
| 182 | `${operateId}_${saveImgId}.${imgFile?.file.type.split('/')[1]}`, |
| 183 | blob!, |
| 184 | ); |
| 185 | const cover = await formatImg({ |
| 186 | blob: blob!, |
| 187 | path: imgPath, |
| 188 | }); |
| 189 | onChoosed(cover); |
| 190 | close(); |
| 191 | }, 'image/png'); |
| 192 | }} |
| 193 | > |
| 194 | <Spin spinning={videoCoverLoading}> |
| 195 | <div className={styles.videoCoverSetingModal}> |
| 196 | <div className="videoCoverSetingModal-top"> |
| 197 | <Alert |
| 198 | message="支持常用图片格式上传,暂不支持 GIF,上传后图片将按平台要求自动裁剪" |
| 199 | type="info" |
| 200 | showIcon |
| 201 | /> |
| 202 | <ImgChoose |
| 203 | onChoose={(imgFile) => { |
| 204 | if (!imgFile) return; |
| 205 | setImgFile(imgFile); |
| 206 | }} |
| 207 | > |
| 208 | <Button>本地上传</Button> |
| 209 | </ImgChoose> |
| 210 | </div> |
| 211 | |
| 212 | <div className="videoCoverSetingModal-cropper"> |
| 213 | <img |
| 214 | style={{ opacity: imgFile?.imgUrl ? '1' : '0' }} |
| 215 | ref={cropperImg} |
| 216 | src={imgFile?.imgUrl || '/'} |
| 217 | /> |
| 218 | </div> |
| 219 | |
| 220 | <Slider |
| 221 | value={sliderVal} |
| 222 | style={{ margin: '50px 0' }} |
| 223 | step={1} |
| 224 | min={0} |
| 225 | max={videoFile?.duration} |
| 226 | onChange={setSliderVal} |
| 227 | onChangeComplete={getVideoCover} |
| 228 | /> |
| 229 | </div> |
| 230 | </Spin> |
| 231 | </Modal> |
| 232 | </> |
| 233 | ); |
| 234 | }, |
| 235 | ), |
| 236 | ); |
| 237 | VideoCoverSeting.displayName = 'VideoCoverSeting'; |
| 238 | |
| 239 | export default VideoCoverSeting; |
| 240 |