Compare commits

...

9 Commits

Author SHA1 Message Date
Samuel Newman d532f053ae 3mbps 2024-06-19 16:42:47 +01:00
Samuel Newman 6fc3c6e28e mess around with adding a thumbnail 2024-06-18 18:33:29 +01:00
Samuel Newman 527e800d01 rework web compression a bit 2024-06-18 18:33:29 +01:00
Samuel Newman e2223fe3ed (WIP) add lonestar compression 2024-06-18 18:33:28 +01:00
Samuel Newman ed17e94cec move logic out of compressVideo 2024-06-18 18:33:28 +01:00
Samuel Newman 6a54959080 add progress component 2024-06-18 18:33:28 +01:00
Samuel Newman 1412d2a544 up res to 1080p 2024-06-18 18:33:28 +01:00
Samuel Newman af02488812 get select video button + compression working 2024-06-18 18:33:28 +01:00
Samuel Newman 4bd0ff8ba2 add ffmpeg-kit-react-native 2024-06-18 18:33:28 +01:00
15 changed files with 480 additions and 6 deletions
+1
View File
@@ -202,6 +202,7 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
['@config-plugins/ffmpeg-kit-react-native', {package: 'min-gpl'}],
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 370 B

+2
View File
@@ -52,6 +52,7 @@
"@atproto/api": "^0.12.18",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2",
@@ -137,6 +138,7 @@
"expo-updates": "~0.25.14",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"ffmpeg-kit-react-native": "^6.0.2",
"history": "^5.3.0",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z',
})
+52
View File
@@ -0,0 +1,52 @@
import * as FileSystem from 'expo-file-system'
import {
FFmpegKit,
FFmpegSessionCompleteCallback,
ReturnCode,
} from 'ffmpeg-kit-react-native'
const PRESET = 'faster'
export type CompressedVideo = {
uri: string
size: number
}
export async function compressVideo(
file: string,
callbacks?: {
onProgress: (progress: number) => void
},
) {
const {onProgress} = callbacks || {}
const ext = file.split('.').pop()
const newFile = file.replace(`.${ext}`, '.compressed.mp4')
const result = await new Promise((resolve: FFmpegSessionCompleteCallback) =>
FFmpegKit.executeAsync(
`-i ${file} -c:v libx264 -crf 25 -preset ${PRESET} -b:v 3M -vf "scale='if(gt(a,1),min(1920,iw),-1)':'if(gt(a,1),-1,min(1920,ih))'" -t 90 -c:a aac -b:a 320k -movflags +faststart ${newFile}`,
resolve,
undefined,
stats => onProgress?.(stats.getTime()),
),
)
const success = ReturnCode.isSuccess(await result.getReturnCode())
if (success) {
await FileSystem.deleteAsync(file)
const res = await FileSystem.getInfoAsync(newFile, {size: true})
if (res.exists) {
console.log('compressed size', (res.size / 1024 / 1024).toFixed(2) + 'mb')
return {
success,
video: {uri: newFile, size: res.size} as CompressedVideo,
}
} else {
throw new Error('Could not find output video')
}
} else {
return {success: false}
}
}
+141
View File
@@ -0,0 +1,141 @@
import * as Toast from '#/view/com/util/Toast'
const MAX_WIDTH = 1920
const MAX_HEIGHT = 1920
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
export async function compressVideo(
file: string,
callbacks?: {
onProgress: (progress: number) => void
},
) {
const {onProgress} = callbacks || {}
const blob = await fetch(file).then(res => res.blob())
const objectUrl = URL.createObjectURL(blob)
const videoEl = document.createElement('video')
videoEl.setAttribute('playsinline', 'playsinline')
videoEl.setAttribute('controls', 'controls')
videoEl.setAttribute('muted', 'muted')
videoEl.setAttribute('src', objectUrl)
try {
await new Promise((resolve, reject) => {
videoEl.addEventListener('error', reject, {once: true})
videoEl.addEventListener('loadedmetadata', resolve, {once: true})
})
} catch (e) {
console.error(e)
Toast.show('Failed to load video, this video format may not be supported')
}
let {videoWidth, videoHeight} = videoEl
let outputWidth = videoWidth
let outputHeight = videoHeight
if (outputWidth > outputHeight) {
if (outputWidth > MAX_WIDTH) {
const scale = MAX_WIDTH / outputWidth
outputWidth = Math.round(outputWidth * scale)
outputHeight = Math.round(outputHeight * scale)
}
} else {
if (outputHeight > MAX_HEIGHT) {
const scale = MAX_HEIGHT / outputHeight
outputWidth = Math.round(outputWidth * scale)
outputHeight = Math.round(outputHeight * scale)
}
}
if (outputWidth % 2 === 1) outputWidth--
if (outputHeight % 2 === 1) outputHeight--
console.log({outputWidth, outputHeight})
const canvas = document.createElement('canvas')
canvas.width = outputWidth
canvas.height = outputHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
ctx.fillStyle = '#fff'
try {
let wasTruncated = false
const videoBlob = await new Promise<Blob>(async resolve => {
const chunks: Blob[] = []
let options = {
mimeType: getSupportedMimeType(),
videoBitsPerSecond: 200000,
}
const recorder = new MediaRecorder(canvas.captureStream(25), options)
recorder.onerror = console.log
recorder.ondataavailable = e => {
let size = chunks.reduce((acc, chunk) => acc + chunk.size, 0)
if (size + e.data.size > MAX_VIDEO_SIZE) {
wasTruncated = true
recorder.stop()
} else {
chunks.push(e.data)
}
}
recorder.onstop = () => {
resolve(new Blob(chunks, {type: recorder.mimeType}))
}
videoEl.play()
recorder.start()
let lastCapture = Date.now()
while (
recorder.state === 'recording' &&
videoEl.currentTime < videoEl.duration
) {
await new Promise(r => setTimeout(r, 1)) // NOTE: don't use requestAnimationFrame because it pauses with the tab isnt focused
onProgress?.(videoEl.currentTime / videoEl.duration)
ctx.fillRect(0, 0, outputWidth, outputHeight)
ctx.drawImage(
videoEl,
0,
0,
videoWidth,
videoHeight,
0,
0,
outputWidth,
outputHeight,
)
if (Date.now() - lastCapture > 500) {
recorder.requestData()
lastCapture = Date.now()
}
}
if (recorder.state === 'recording') {
recorder.stop()
}
})
if (wasTruncated) {
Toast.show('Video was too long and was truncated')
}
return {
uri: URL.createObjectURL(videoBlob),
}
} catch (err) {
console.error(err)
Toast.show('Failed to compress video')
} finally {
URL.revokeObjectURL(objectUrl)
}
}
function getSupportedMimeType() {
if (MediaRecorder.isTypeSupported('video/mp4;codecs=h264')) {
return 'video/mp4;codecs=h264'
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=h264')) {
return 'video/webm;codecs=h264'
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
return 'video/webm;codecs=vp9'
} else {
throw new Error('No supported video codec found')
}
}
+28 -3
View File
@@ -91,6 +91,10 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import hairlineWidth = StyleSheet.hairlineWidth
import {SelectVideoBtn} from './videos/SelectVideoBtn'
import {useVideoState} from './videos/state'
import {VideoPreview} from './videos/VideoPreview'
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
type CancelRef = {
onPressCancel: () => void
@@ -150,6 +154,14 @@ export const ComposePost = observer(function ComposePost({
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote,
)
const {
video,
onSelectVideo,
videoPending,
videoProcessingData,
clearVideo,
videoProcessingProgress,
} = useVideoState({setError})
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
@@ -358,8 +370,9 @@ export const ComposePost = observer(function ComposePost({
? _(msg`Write your reply`)
: _(msg`What's up?`)
const canSelectImages = gallery.size < 4 && !extLink
const hasMedia = gallery.size > 0 || Boolean(extLink)
const canSelectImages =
gallery.size < 4 && !extLink && !video && !videoPending
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
@@ -583,7 +596,15 @@ export const ComposePost = observer(function ComposePost({
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : undefined}
) : null}
{videoPending && videoProcessingData ? (
<VideoTranscodeProgress
input={videoProcessingData}
progress={videoProcessingProgress}
/>
) : (
video && <VideoPreview video={video} clear={clearVideo} />
)}
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
@@ -602,6 +623,10 @@ export const ComposePost = observer(function ComposePost({
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
<SelectVideoBtn
onSelectVideo={onSelectVideo}
disabled={!canSelectImages}
/>
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
@@ -1,13 +1,14 @@
import React from 'react'
import {View} from 'react-native'
import {Text} from '../../util/text/Text'
// @ts-ignore no type definition -prf
import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {Text} from '../../util/text/Text'
const DANGER_LENGTH = MAX_GRAPHEME_LENGTH
@@ -0,0 +1,60 @@
import React, {useCallback} from 'react'
import {
ImagePickerAsset,
launchImageLibraryAsync,
MediaTypeOptions,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
const VIDEO_MAX_DURATION = 90
type Props = {
onSelectVideo: (video: ImagePickerAsset) => void
disabled?: boolean
}
export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
const {_} = useLingui()
const t = useTheme()
const onPressSelectVideo = useCallback(async () => {
const response = await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Videos,
videoMaxDuration: VIDEO_MAX_DURATION,
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
})
if (response.assets && response.assets.length > 0) {
onSelectVideo(response.assets[0])
}
}, [onSelectVideo])
return (
<>
<Button
testID="openGifBtn"
onPress={onPressSelectVideo}
label={_(msg`Select GIF`)}
accessibilityHint={_(msg`Opens GIF select dialog`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<VideoClipIcon
size="lg"
style={disabled && t.atoms.text_contrast_low}
/>
</Button>
</>
)
}
@@ -0,0 +1,27 @@
import React from 'react'
import {CompressedVideo} from '#/lib/media/video/compress'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
export function VideoPreview({
video,
clear,
}: {
video: CompressedVideo
clear: () => void
}) {
return (
<>
<Text>{JSON.stringify(video, null, 2)}</Text>
<Button
onPress={clear}
label="Clear"
size="small"
color="primary"
variant="solid">
<ButtonText>Clear</ButtonText>
</Button>
</>
)
}
@@ -0,0 +1,39 @@
import React from 'react'
import Animated, {FadeIn} from 'react-native-reanimated'
import * as FileSystem from 'expo-file-system'
import {Image} from 'expo-image'
import {useQuery} from '@tanstack/react-query'
import {FFmpegKit} from 'ffmpeg-kit-react-native'
import {atoms as a} from '#/alf'
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
const {data: thumbnail} = useQuery({
queryKey: ['thumbnail', uri],
queryFn: async () => {
const thumbnailJpg = `${FileSystem.cacheDirectory}/thumbnail.jpg`
if ((await FileSystem.getInfoAsync(thumbnailJpg)).exists) {
await FileSystem.deleteAsync(thumbnailJpg)
}
await FFmpegKit.execute(
`-ss 00:00:01.000 -i ${uri} -vf 'scale=320:320:force_original_aspect_ratio=decrease' -frames:v 1 ${thumbnailJpg}`,
)
return thumbnailJpg
},
})
return (
<Animated.View style={a.flex_1} entering={FadeIn}>
{thumbnail && (
<Image
style={a.flex_1}
source={thumbnail}
cachePolicy="none"
accessibilityIgnoresInvertColors
blurRadius={15}
contentFit="cover"
/>
)}
</Animated.View>
)
}
@@ -0,0 +1,5 @@
import React from 'react'
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
return <video src={uri} style={{flex: 1}} muted />
}
@@ -0,0 +1,56 @@
import React from 'react'
import {View} from 'react-native'
// @ts-expect-error no type definition
import ProgressPie from 'react-native-progress/Pie'
import {ImagePickerAsset} from 'expo-image-picker'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
export function VideoTranscodeProgress({
input,
progress,
}: {
input: ImagePickerAsset
progress: number
}) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.mt_md,
t.atoms.bg_contrast_50,
a.rounded_md,
a.overflow_hidden,
{aspectRatio: Math.max(input.width / input.height, 16 / 9)},
]}>
<VideoTranscodeBackdrop uri={input.uri} />
<View
style={[
a.flex_1,
a.align_center,
a.justify_center,
a.gap_lg,
a.absolute,
a.inset_0,
]}>
{input.duration ? (
<ProgressPie
size={64}
borderWidth={4}
borderColor={t.atoms.text.color}
color={t.atoms.text.color}
progress={progress}
/>
) : (
<Loader size="xl" />
)}
<Text>Compressing...</Text>
</View>
</View>
)
}
+47
View File
@@ -0,0 +1,47 @@
import {useState} from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {compressVideo} from '#/lib/media/video/compress'
export function useVideoState({setError}: {setError: (error: string) => void}) {
const {_} = useLingui()
const [progress, setProgress] = useState(0)
const {mutate, data, isPending, isError, reset, variables} = useMutation({
mutationFn: async (asset: ImagePickerAsset) => {
console.log(
'uncompressed size',
((asset.fileSize ?? 0) / 1024 / 1024).toFixed(2) + 'mb',
)
const compressed = await compressVideo(asset.uri, {
onProgress: progressMs => {
if (asset.duration) {
setProgress(progressMs / asset.duration)
}
},
})
return compressed
},
onError: error => {
console.error('error', error)
setError(_(msg`Could not compress video`))
},
onMutate: () => {
setProgress(0)
},
})
return {
video: data?.video,
onSelectVideo: mutate,
videoPending: isPending,
videoProcessingData: variables,
videoError: isError,
clearVideo: reset,
videoProcessingProgress: progress,
}
}
+12
View File
@@ -2929,6 +2929,13 @@
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.1.1.tgz#21b11a1a3f18c3e7d62fd5f87438b7ed2c64c1f7"
integrity sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==
"@config-plugins/ffmpeg-kit-react-native@^8.0.0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/@config-plugins/ffmpeg-kit-react-native/-/ffmpeg-kit-react-native-8.0.0.tgz#86d6f236bcab5b1d3faa768cea0e7508c25be544"
integrity sha512-PUQHUBfRaGMBwPM8KnfHNVcWH0WvWO9QgIJYkb55/WGSmIdezsgJo/zlcXFexe7kkSTBqG/Pxnp5DGTYFBYBuQ==
dependencies:
semver "^7.3.5"
"@connectrpc/connect-express@^1.1.4":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@connectrpc/connect-express/-/connect-express-1.3.0.tgz#605cb536e041f5866868421ae00b1a794dcdd1ed"
@@ -12512,6 +12519,11 @@ fetch-retry@^4.1.1:
resolved "https://registry.yarnpkg.com/fetch-retry/-/fetch-retry-4.1.1.tgz#fafe0bb22b54f4d0a9c788dff6dd7f8673ca63f3"
integrity sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==
ffmpeg-kit-react-native@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/ffmpeg-kit-react-native/-/ffmpeg-kit-react-native-6.0.2.tgz#9eeac96ad89367c99480bd90431391405d4eb73e"
integrity sha512-r9uSmahq8TeyIb7fXf3ft+uUXyoeWRFa99+khjo0TAzWO9y0z9wU7eGnab9JLw1MmCB9v64o4yojNluJhVm9nQ==
figures@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"