返回 AiToEarn
VideoCoverSeting.tsx
1 /**
2 * VideoCoverSeting - 视频封面设置组件
3 * 支持视频截帧、图片选择、比例裁剪等功能
4 */
5
6 import type { IImgFile, IVideoFile } from '@/components/PublishDialog/publishDialog.type'
7 import Cropper from 'cropperjs'
8 import { Loader2, Upload } from 'lucide-react'
9 import { memo, useCallback, useEffect, useRef, useState } from 'react'
10 import { toast } from 'sonner'
11 import { uploadToOss } from '@/api/materials/material.api'
12 import { useTransClient } from '@/app/i18n/client'
13 import ImgChoose from '@/components/PublishDialog/compoents/Choose/ImgChoose'
14 import { formatImg, VideoGrabFrame } from '@/components/PublishDialog/PublishDialog.util'
15 import { Button } from '@/components/ui/button'
16 import { Modal } from '@/components/ui/modal'
17 import { Slider } from '@/components/ui/slider'
18 import { cn } from '@/utils/className'
19 import { getOssUrl } from '@/utils/oss'
20 import 'cropperjs/dist/cropper.css'
21
22 /** 比例预设配置 */
23 const ASPECT_RATIOS = [
24 { label: 'free', value: undefined },
25 { label: '16:9', value: 16 / 9 },
26 { label: '9:16', value: 9 / 16 },
27 { label: '1:1', value: 1 },
28 ] as const
29
30 /** 格式化时间为 MM:SS 格式 */
31 function formatTime(seconds: number): string {
32 const mins = Math.floor(seconds / 60)
33 const secs = Math.floor(seconds % 60)
34 return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
35 }
36
37 export interface IVideoCoverSetingProps {
38 /** 封面选择完成回调 */
39 onChoosed: (imgFile: IImgFile) => void
40 /** 当前选择的封面 */
41 value?: IImgFile
42 /** 需要截帧的视频 */
43 videoFile?: IVideoFile
44 /** 保存图片的唯一值 */
45 saveImgId?: string
46 /** 关闭弹框回调 */
47 onClose: () => void
48 /** 弹框显示状态 */
49 videoCoverSetingModal: boolean
50 }
51
52 /**
53 * 视频封面设置组件
54 * 提供视频截帧、本地图片上传和裁剪功能
55 */
56 const VideoCoverSeting = memo(
57 ({
58 videoCoverSetingModal,
59 onChoosed,
60 value,
61 videoFile,
62 saveImgId = '',
63 onClose,
64 }: IVideoCoverSetingProps) => {
65 const { t } = useTransClient('publish')
66 const [imgFile, setImgFile] = useState<IImgFile>()
67 const cropper = useRef<Cropper>()
68 const cropperImg = useRef<HTMLImageElement>(null)
69 const [videoCoverLoading, setVideoCoverLoading] = useState(false)
70 const [sliderVal, setSliderVal] = useState(0)
71 const [uploadLoading, setUploadLoading] = useState(false)
72 const [aspectRatio, setAspectRatio] = useState<number | undefined>(undefined)
73
74 // 初始化:弹框打开时加载封面
75 useEffect(() => {
76 if (!videoCoverSetingModal)
77 return
78 if (value) {
79 setImgFile(value)
80 return
81 }
82 getVideoCover(0)
83 }, [videoCoverSetingModal])
84
85 /** 获取视频截帧封面 */
86 const getVideoCover = useCallback(
87 async (n: number) => {
88 if (!videoFile?.videoUrl)
89 return
90 setVideoCoverLoading(true)
91 try {
92 const videoInfo = await VideoGrabFrame(videoFile.videoUrl, n)
93 setImgFile(videoInfo.cover)
94 }
95 catch (error) {
96 console.error('获取视频封面失败:', error)
97 toast.error(t('videoCover.grabFrameFailed'))
98 }
99 finally {
100 setVideoCoverLoading(false)
101 }
102 },
103 [videoFile?.videoUrl, t],
104 )
105
106 /** 关闭弹框 */
107 const handleClose = useCallback(() => {
108 onClose()
109 }, [onClose])
110
111 /** 销毁裁剪工具 */
112 const destroyCropper = useCallback(() => {
113 if (!cropper.current)
114 return
115
116 cropper.current.destroy()
117 cropper.current = undefined
118 }, [])
119
120 /** 初始化裁剪工具 */
121 const initCropper = useCallback(() => {
122 const image = cropperImg.current
123 if (!image || !imgFile?.imgUrl || !image.complete || image.naturalWidth === 0)
124 return
125
126 // 销毁旧的裁剪器实例
127 destroyCropper()
128
129 cropper.current = new Cropper(image, {
130 viewMode: 1,
131 autoCropArea: 1,
132 responsive: true,
133 guides: true,
134 center: true,
135 highlight: true,
136 background: true,
137 aspectRatio,
138 minCropBoxWidth: 50,
139 minCropBoxHeight: 50,
140 })
141 }, [aspectRatio, destroyCropper, imgFile?.imgUrl])
142
143 // 图片加载完成后初始化裁剪器,避免首次进入时图片尺寸未就绪
144 useEffect(() => {
145 if (!videoCoverSetingModal)
146 return
147 initCropper()
148 }, [imgFile?.imgUrl, initCropper, videoCoverSetingModal])
149
150 // 弹框关闭或组件卸载时清理裁剪器实例
151 useEffect(() => {
152 if (videoCoverSetingModal)
153 return
154 destroyCropper()
155 }, [destroyCropper, videoCoverSetingModal])
156
157 useEffect(() => destroyCropper, [destroyCropper])
158
159 /** 图片加载完成 */
160 const handleImageLoad = useCallback(() => {
161 if (!videoCoverSetingModal)
162 return
163 initCropper()
164 }, [initCropper, videoCoverSetingModal])
165
166 /** 切换裁剪比例 */
167 const handleAspectRatioChange = useCallback((ratio: number | undefined) => {
168 setAspectRatio(ratio)
169 // 如果裁剪器已存在,直接更新比例
170 if (cropper.current) {
171 cropper.current.setAspectRatio(ratio ?? Number.NaN)
172 }
173 }, [])
174
175 /** 确认选择封面 */
176 const handleConfirm = useCallback(async () => {
177 if (!cropper.current || !imgFile)
178 return
179
180 setUploadLoading(true)
181 try {
182 const canvas = cropper.current.getCroppedCanvas()
183 const blob = await new Promise<Blob | null>((resolve) => {
184 canvas.toBlob(resolve, 'image/jpeg', 0.92)
185 })
186
187 if (!blob) {
188 throw new Error('Failed to create blob')
189 }
190
191 const cover = await formatImg({
192 blob,
193 path: `${saveImgId}.jpg`,
194 })
195
196 // 上传封面到 OSS
197 const uploadCoverRes = await uploadToOss(cover.file)
198 cover.ossUrl = getOssUrl(uploadCoverRes)
199
200 onChoosed(cover)
201 handleClose()
202 }
203 catch (error) {
204 console.error('上传封面失败:', error)
205 toast.error(t('videoCover.uploadFailed'))
206 }
207 finally {
208 setUploadLoading(false)
209 }
210 }, [imgFile, saveImgId, onChoosed, handleClose, t])
211
212 /** 处理本地图片选择 */
213 const handleImageChoose = useCallback((selectedImgFile: IImgFile | undefined) => {
214 if (!selectedImgFile)
215 return
216 setImgFile(selectedImgFile)
217 }, [])
218
219 /** 处理滑块值变化 */
220 const handleSliderChange = useCallback((values: number[]) => {
221 setSliderVal(values[0])
222 }, [])
223
224 /** 处理滑块值提交(截帧) */
225 const handleSliderCommit = useCallback(
226 (values: number[]) => {
227 getVideoCover(values[0])
228 },
229 [getVideoCover],
230 )
231
232 return (
233 <Modal
234 width={700}
235 title={t('videoCover.title')}
236 maskClosable={false}
237 open={videoCoverSetingModal}
238 onCancel={handleClose}
239 footer={(
240 <>
241 <Button
242 variant="outline"
243 onClick={handleClose}
244 className="cursor-pointer"
245 disabled={uploadLoading}
246 >
247 {t('buttons.cancel')}
248 </Button>
249 <Button
250 disabled={uploadLoading || !imgFile}
251 onClick={handleConfirm}
252 className="cursor-pointer"
253 >
254 {uploadLoading && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
255 {t('buttons.confirm')}
256 </Button>
257 </>
258 )}
259 >
260 <div className="flex flex-col gap-4">
261 {/* 裁剪区域 - 深色背景容器 */}
262 <div className="relative rounded-lg overflow-hidden bg-neutral-900 dark:bg-neutral-950">
263 {/* 加载遮罩 */}
264 {videoCoverLoading && (
265 <div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center z-10 gap-2">
266 <Loader2 className="h-8 w-8 animate-spin text-white" />
267 <span className="text-sm text-white/80">{t('videoCover.loading')}</span>
268 </div>
269 )}
270
271 {/* 裁剪器容器 */}
272 <div className="h-[240px] sm:h-[340px] flex items-center justify-center p-2 sm:p-4">
273 <img
274 ref={cropperImg}
275 src={imgFile?.imgUrl || '/'}
276 alt={t('videoCover.coverPreview')}
277 className={cn('max-h-full max-w-full', !imgFile?.imgUrl && 'opacity-0')}
278 onLoad={handleImageLoad}
279 />
280 </div>
281 </div>
282
283 {/* 工具栏 */}
284 <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4 px-1">
285 {/* 比例切换按钮组 */}
286 <div className="flex flex-col sm:flex-row sm:items-center gap-2">
287 <span className="text-sm text-muted-foreground shrink-0">
288 {t('videoCover.aspectRatio')}
289 </span>
290 <div className="flex items-center gap-1 bg-muted rounded-md p-1 overflow-x-auto">
291 {ASPECT_RATIOS.map(ratio => (
292 <button
293 key={ratio.label}
294 type="button"
295 onClick={() => handleAspectRatioChange(ratio.value)}
296 className={cn(
297 'px-2.5 sm:px-3 py-1 text-sm rounded transition-colors cursor-pointer whitespace-nowrap',
298 aspectRatio === ratio.value
299 ? 'bg-background text-foreground shadow-sm'
300 : 'text-muted-foreground hover:text-foreground',
301 )}
302 >
303 {ratio.label === 'free' ? t('videoCover.free') : ratio.label}
304 </button>
305 ))}
306 </div>
307 </div>
308
309 {/* 分隔符 - 仅桌面端显示 */}
310 <div className="hidden sm:block w-px h-6 bg-border" />
311
312 {/* 本地上传按钮 */}
313 <ImgChoose onChoose={handleImageChoose}>
314 <Button
315 variant="outline"
316 size="sm"
317 className="cursor-pointer shrink-0 gap-1.5 w-full sm:w-auto"
318 >
319 <Upload className="h-4 w-4" />
320 {t('videoCover.localUpload')}
321 </Button>
322 </ImgChoose>
323 </div>
324
325 {/* 时间轴 */}
326 <div className="flex flex-col gap-3 px-1">
327 {/* 标签和时间显示 */}
328 <div className="flex items-center justify-between">
329 <span className="text-sm text-muted-foreground">{t('videoCover.selectFrame')}</span>
330 <span className="text-sm font-mono text-muted-foreground">
331 {formatTime(sliderVal)}
332 {' '}
333 /
334 {formatTime(videoFile?.duration || 0)}
335 </span>
336 </div>
337
338 {/* 滑块 */}
339 <Slider
340 value={[sliderVal]}
341 step={1}
342 min={0}
343 max={videoFile?.duration || 100}
344 onValueChange={handleSliderChange}
345 onValueCommit={handleSliderCommit}
346 />
347 </div>
348 </div>
349 </Modal>
350 )
351 },
352 )
353 VideoCoverSeting.displayName = 'VideoCoverSeting'
354
355 export default VideoCoverSeting
356
356 lines Plain Text