useAnimatedImageValue

This commit is contained in:
郭文文 2026-01-20 11:46:12 +08:00
parent f2f460850c
commit aebbb13f87
2 changed files with 84 additions and 39 deletions

View File

@ -1,11 +1,11 @@
import { Image, type ImageRef } from 'expo-image' import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { type ViewStyle } from 'react-native' import { type ViewStyle } from 'react-native'
import { useSharedValue } from 'react-native-reanimated' import { useDerivedValue, useSharedValue } from 'react-native-reanimated'
import { import {
Canvas, Canvas,
Image, Image as SkiaImage,
useAnimatedImageValue,
useImage, useImage,
useVideo useVideo
} from "@shopify/react-native-skia"; } from "@shopify/react-native-skia";
@ -17,12 +17,13 @@ type Props = {
style?: ViewStyle style?: ViewStyle
width?: number width?: number
autoplay?: boolean // 控制动画播放,默认 true autoplay?: boolean // 控制动画播放,默认 true
} & React.ComponentProps<typeof Video> }
// 默认宽度256半屏宽度 // 默认宽度256半屏宽度
const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ...videoProps }: Props) => { const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true }: Props) => {
const [urlFinal, setUrlFinal] = useState('') const [urlFinal, setUrlFinal] = useState('')
const imageRef = useRef<ImageRef>(null) const pausedValue = useSharedValue(!autoplay)
const looping = useSharedValue(true)
const createUrl = (url: string) => { const createUrl = (url: string) => {
return `https://modal-dev.bowong.cc/api/custom/video/converter/v2?media_url=${encodeURI(url)}&options=compression_level=3,quality=70,loop=true,resolution=${width}x${width},fps=24` return `https://modal-dev.bowong.cc/api/custom/video/converter/v2?media_url=${encodeURI(url)}&options=compression_level=3,quality=70,loop=true,resolution=${width}x${width},fps=24`
@ -34,6 +35,15 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
return lowerUrl?.match(/\.(jpg|jpeg|png|gif|webp|bmp|tiff|svg)(\?.*)?$/i) return lowerUrl?.match(/\.(jpg|jpeg|png|gif|webp|bmp|tiff|svg)(\?.*)?$/i)
} }
// 检测是否为动画图片GIF 或动画 WebP
const isAnimatedImage = (u?: string | null) => {
if (!u) return false
const lowerUrl = u.toLowerCase()
// GIF 文件通常是动画的
// WebP 可能是动画的,需要通过内容判断,这里先简单判断扩展名
return lowerUrl.includes('.gif') || lowerUrl.includes('.webp')
}
async function resolveRedirect(url: string) { async function resolveRedirect(url: string) {
const res = await fetch(url, { const res = await fetch(url, {
method: 'GET', method: 'GET',
@ -85,14 +95,8 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
// 控制动画播放/停止 // 控制动画播放/停止
useEffect(() => { useEffect(() => {
if (imageRef?.current) { pausedValue.value = !autoplay
if (autoplay) { }, [autoplay, pausedValue])
imageRef.current?.startAnimating()
} else {
imageRef.current?.stopAnimating()
}
}
}, [autoplay])
if (!url) return null if (!url) return null
@ -122,13 +126,25 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
// 判断是否为图片(需要检查原始 URL 和最终 URL // 判断是否为图片(需要检查原始 URL 和最终 URL
const shouldUseImage = isImageFile || (displayUrl ? isImg(displayUrl) : false) const shouldUseImage = isImageFile || (displayUrl ? isImg(displayUrl) : false)
// 判断是否为动画图片
const shouldUseAnimatedImage = shouldUseImage && isAnimatedImage(displayUrl)
// 判断是否为静态图片
const shouldUseStaticImage = shouldUseImage && !shouldUseAnimatedImage
// 只有在明确是视频链接且不是图片时才调用 useVideo避免触发 Skia.Video 原生异常 // 只有在明确是视频链接且不是图片时才调用 useVideo避免触发 Skia.Video 原生异常
const shouldUseVideo = !shouldUseImage && isVideoUrl(displayUrl) const shouldUseVideo = !shouldUseImage && isVideoUrl(displayUrl)
// Hooks 必须在顶层调用,不能条件调用 // Hooks 必须在顶层调用,不能条件调用
// 对于图片,使用 useImage对于视频使用 useVideo // 对于静态图片,使用 useImage
// 对于动画图片,使用 useAnimatedImageValue支持 autoplay 控制)
// 对于视频,使用 useVideo
// 如果 displayUrl 为 nullhooks 会返回 null // 如果 displayUrl 为 nullhooks 会返回 null
const image = useImage(shouldUseImage ? displayUrl : null) const staticImage = useImage(shouldUseStaticImage ? displayUrl : null)
const animatedImage = useAnimatedImageValue(
shouldUseAnimatedImage ? displayUrl : null,
shouldUseAnimatedImage ? pausedValue : undefined
)
const videoResult = useVideo( const videoResult = useVideo(
shouldUseVideo ? displayUrl : null, shouldUseVideo ? displayUrl : null,
{ {
@ -137,38 +153,67 @@ const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true, ..
} }
) )
// 使用 useDerivedValue 从 videoResult.currentFrame 获取当前帧
// 如果 videoResult 为 nullvideoFrame 也会是 null
const videoFrame = useDerivedValue(() => {
return videoResult?.currentFrame.value ?? null
})
if (!url || !displayUrl) return null if (!url || !displayUrl) return null
const canvasStyle = style || {} const canvasStyle = style || {}
const canvasWidth = (canvasStyle.width as number) || width const canvasWidth = (canvasStyle.width as number) || width
const canvasHeight = (canvasStyle.height as number) || width const canvasHeight = (canvasStyle.height as number) || width
// 如果是图片且已加载 // 如果是静态图片且已加载
if (shouldUseImage && image) { if (shouldUseStaticImage && staticImage) {
return ( return (
<Image <Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
cachePolicy="disk" <SkiaImage
source={{ uri: url }} image={staticImage}
style={style as any} x={0}
transition={{ duration: 200, effect: 'cross-dissolve' }} y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/> />
</Canvas>
) )
} }
// 如果是动画图片GIF 或动画 WebP
if (shouldUseAnimatedImage && animatedImage) {
return ( return (
// 移除 key 避免组件重建导致闪烁,使用 transition 实现平滑切换 <Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<Image <SkiaImage
ref={imageRef} image={animatedImage}
// 只使用 disk 缓存,减少内存占用 x={0}
cachePolicy="disk" y={0}
// 添加 recyclingKey 帮助内存回收 width={canvasWidth}
recyclingKey={urlFinal} height={canvasHeight}
source={{ uri: urlFinal }} fit="cover"
style={style as any}
autoplay={autoplay}
transition={{ duration: 200, effect: 'cross-dissolve' }}
/> />
</Canvas>
) )
} }
// 如果是视频,使用 currentFrame SharedValue
if (shouldUseVideo && videoResult && videoFrame) {
return (
<Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<SkiaImage
image={videoFrame}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/>
</Canvas>
)
}
return null
}
export default memo(VideoBox) export default memo(VideoBox)

BIN
public/canvaskit.wasm Normal file

Binary file not shown.