Merge remote-tracking branch 'origin/main' into app-2458
# Conflicts: # src/lib/media/video/constants.ts
This commit is contained in:
@@ -204,6 +204,7 @@
|
|||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.shuffle": "^4.2.0",
|
"lodash.shuffle": "^4.2.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"mediabunny": "^1.25.3",
|
||||||
"multiformats": "^13.4.2",
|
"multiformats": "^13.4.2",
|
||||||
"nanoid": "^5.0.5",
|
"nanoid": "^5.0.5",
|
||||||
"normalize-url": "^8.0.0",
|
"normalize-url": "^8.0.0",
|
||||||
|
|||||||
Generated
+23
@@ -570,6 +570,9 @@ importers:
|
|||||||
lodash.throttle:
|
lodash.throttle:
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
|
mediabunny:
|
||||||
|
specifier: ^1.25.3
|
||||||
|
version: 1.49.0
|
||||||
multiformats:
|
multiformats:
|
||||||
specifier: ^13.4.2
|
specifier: ^13.4.2
|
||||||
version: 13.4.2
|
version: 13.4.2
|
||||||
@@ -3494,6 +3497,12 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-transform@0.1.11':
|
||||||
|
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||||
|
|
||||||
|
'@types/dom-webcodecs@0.1.13':
|
||||||
|
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||||
|
|
||||||
@@ -6849,6 +6858,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mediabunny@1.49.0:
|
||||||
|
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
|
||||||
|
|
||||||
memfs@3.5.3:
|
memfs@3.5.3:
|
||||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||||
engines: {node: '>= 4.0.0'}
|
engines: {node: '>= 4.0.0'}
|
||||||
@@ -12748,6 +12760,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.12.4
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-transform@0.1.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/dom-webcodecs': 0.1.13
|
||||||
|
|
||||||
|
'@types/dom-webcodecs@0.1.13': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@@ -16664,6 +16682,11 @@ snapshots:
|
|||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
|
|
||||||
|
mediabunny@1.49.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/dom-mediacapture-transform': 0.1.11
|
||||||
|
'@types/dom-webcodecs': 0.1.13
|
||||||
|
|
||||||
memfs@3.5.3:
|
memfs@3.5.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
fs-monkey: 1.1.0
|
fs-monkey: 1.1.0
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export function ImageEmbed({
|
|||||||
onPress(0, [containerRef], [dims])
|
onPress(0, [containerRef], [dims])
|
||||||
}
|
}
|
||||||
onPressIn={() => onPressIn(0)}
|
onPressIn={() => onPressIn(0)}
|
||||||
hideBadge={rest.isWithinQuote}
|
|
||||||
/>
|
/>
|
||||||
</ImageContextMenu>
|
</ImageContextMenu>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function ConstrainedImage({
|
|||||||
export function AutoSizedImage({
|
export function AutoSizedImage({
|
||||||
image,
|
image,
|
||||||
crop = 'constrained',
|
crop = 'constrained',
|
||||||
hideBadge,
|
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
@@ -74,7 +73,6 @@ export function AutoSizedImage({
|
|||||||
}: {
|
}: {
|
||||||
image: AppBskyEmbedImages.ViewImage
|
image: AppBskyEmbedImages.ViewImage
|
||||||
crop?: 'none' | 'square' | 'constrained'
|
crop?: 'none' | 'square' | 'constrained'
|
||||||
hideBadge?: boolean
|
|
||||||
onPress?: (
|
onPress?: (
|
||||||
containerRef: AnimatedRef<any>,
|
containerRef: AnimatedRef<any>,
|
||||||
fetchedDims: Dimensions | null,
|
fetchedDims: Dimensions | null,
|
||||||
@@ -146,7 +144,7 @@ export function AutoSizedImage({
|
|||||||
/>
|
/>
|
||||||
<MediaInsetBorder />
|
<MediaInsetBorder />
|
||||||
|
|
||||||
{(hasAlt || isCropped) && !hideBadge ? (
|
{hasAlt || isCropped ? (
|
||||||
<View
|
<View
|
||||||
accessible={false}
|
accessible={false}
|
||||||
style={[
|
style={[
|
||||||
@@ -165,8 +163,10 @@ export function AutoSizedImage({
|
|||||||
]}>
|
]}>
|
||||||
{isCropped && (
|
{isCropped && (
|
||||||
<View
|
<View
|
||||||
|
accessible={false}
|
||||||
style={[
|
style={[
|
||||||
a.rounded_xs,
|
a.rounded_sm,
|
||||||
|
a.p_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{
|
{
|
||||||
padding: 3,
|
padding: 3,
|
||||||
@@ -186,17 +186,18 @@ export function AutoSizedImage({
|
|||||||
)}
|
)}
|
||||||
{hasAlt && (
|
{hasAlt && (
|
||||||
<View
|
<View
|
||||||
|
accessible={false}
|
||||||
style={[
|
style={[
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.rounded_xs,
|
a.rounded_sm,
|
||||||
|
a.p_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{
|
{
|
||||||
padding: 3,
|
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
largeAlt && [
|
largeAlt && [
|
||||||
{
|
{
|
||||||
padding: 5,
|
padding: 6,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export function Gallery({
|
|||||||
const bps = useBreakpoints()
|
const bps = useBreakpoints()
|
||||||
const window = useWindowDimensions()
|
const window = useWindowDimensions()
|
||||||
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
|
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
|
||||||
const hideBadges = isWithinQuote
|
|
||||||
const contentHeight = useMemo(() => {
|
const contentHeight = useMemo(() => {
|
||||||
if (isWithinChat) {
|
if (isWithinChat) {
|
||||||
return 120
|
return 120
|
||||||
@@ -250,7 +249,6 @@ export function Gallery({
|
|||||||
onPress?.(index, [containerRef], [dims])
|
onPress?.(index, [containerRef], [dims])
|
||||||
}
|
}
|
||||||
onPressIn={() => onPressIn?.(index)}
|
onPressIn={() => onPressIn?.(index)}
|
||||||
hideBadge={isWithinQuote}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -300,7 +298,6 @@ export function Gallery({
|
|||||||
: undefined
|
: undefined
|
||||||
return (
|
return (
|
||||||
<GalleryImage
|
<GalleryImage
|
||||||
hideBadges={hideBadges}
|
|
||||||
largeAltBadge={largeAltBadge}
|
largeAltBadge={largeAltBadge}
|
||||||
image={item}
|
image={item}
|
||||||
contentHeight={contentHeight}
|
contentHeight={contentHeight}
|
||||||
@@ -396,7 +393,6 @@ function GalleryImage({
|
|||||||
imageCount,
|
imageCount,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
itemRef,
|
itemRef,
|
||||||
hideBadges,
|
|
||||||
largeAltBadge,
|
largeAltBadge,
|
||||||
onContainerRef,
|
onContainerRef,
|
||||||
onThumbDims,
|
onThumbDims,
|
||||||
@@ -410,7 +406,6 @@ function GalleryImage({
|
|||||||
imageCount: number
|
imageCount: number
|
||||||
onWidthChange: (index: number, width: number) => void
|
onWidthChange: (index: number, width: number) => void
|
||||||
itemRef: (node: View | null) => void
|
itemRef: (node: View | null) => void
|
||||||
hideBadges?: boolean
|
|
||||||
largeAltBadge?: boolean
|
largeAltBadge?: boolean
|
||||||
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
|
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
|
||||||
onThumbDims: (index: number, dims: Dimensions) => void
|
onThumbDims: (index: number, dims: Dimensions) => void
|
||||||
@@ -499,7 +494,7 @@ function GalleryImage({
|
|||||||
useAppleWebpCodec
|
useAppleWebpCodec
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!hideBadges && imageCount > 1 ? (
|
{imageCount > 1 ? (
|
||||||
<View
|
<View
|
||||||
accessible={false}
|
accessible={false}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
@@ -532,7 +527,7 @@ function GalleryImage({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(hasAlt || isCropped) && !hideBadges ? (
|
{hasAlt || isCropped ? (
|
||||||
<View
|
<View
|
||||||
accessible={false}
|
accessible={false}
|
||||||
style={[
|
style={[
|
||||||
@@ -549,6 +544,7 @@ function GalleryImage({
|
|||||||
]}>
|
]}>
|
||||||
{isCropped && (
|
{isCropped && (
|
||||||
<View
|
<View
|
||||||
|
accessible={false}
|
||||||
style={[
|
style={[
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.p_xs,
|
a.p_xs,
|
||||||
@@ -568,6 +564,7 @@ function GalleryImage({
|
|||||||
)}
|
)}
|
||||||
{hasAlt && (
|
{hasAlt && (
|
||||||
<View
|
<View
|
||||||
|
accessible={false}
|
||||||
style={[
|
style={[
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
|||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
type EventFunction = (index: number) => void
|
type EventFunction = (index: number) => void
|
||||||
@@ -42,8 +42,6 @@ export function GalleryItem({
|
|||||||
onPress,
|
onPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
viewContext,
|
|
||||||
isWithinQuote,
|
|
||||||
insetBorderStyle,
|
insetBorderStyle,
|
||||||
containerRefs,
|
containerRefs,
|
||||||
thumbDimsRef,
|
thumbDimsRef,
|
||||||
@@ -53,9 +51,6 @@ export function GalleryItem({
|
|||||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||||
const image = images[index]
|
const image = images[index]
|
||||||
const hasAlt = !!image.alt
|
const hasAlt = !!image.alt
|
||||||
const hideBadges =
|
|
||||||
isWithinQuote ??
|
|
||||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
|
||||||
|
|
||||||
const aspect =
|
const aspect =
|
||||||
image.aspectRatio && image.aspectRatio.height > 0
|
image.aspectRatio && image.aspectRatio.height > 0
|
||||||
@@ -114,26 +109,24 @@ export function GalleryItem({
|
|||||||
<MediaInsetBorder style={insetBorderStyle} />
|
<MediaInsetBorder style={insetBorderStyle} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ImageContextMenu>
|
</ImageContextMenu>
|
||||||
{hasAlt && !hideBadges ? (
|
{hasAlt ? (
|
||||||
<View
|
<View
|
||||||
accessible={false}
|
accessible={false}
|
||||||
style={[
|
style={[
|
||||||
a.absolute,
|
a.absolute,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.rounded_xs,
|
a.rounded_sm,
|
||||||
|
a.p_xs,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{
|
{
|
||||||
gap: 3,
|
|
||||||
padding: 3,
|
|
||||||
bottom: a.p_xs.padding,
|
bottom: a.p_xs.padding,
|
||||||
right: a.p_xs.padding,
|
right: a.p_xs.padding,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
largeAltBadge && [
|
largeAltBadge && [
|
||||||
{
|
{
|
||||||
gap: 4,
|
padding: 6,
|
||||||
padding: 5,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
@@ -1,58 +1,329 @@
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
import {
|
||||||
|
ALL_FORMATS,
|
||||||
|
type AudioCodec,
|
||||||
|
BlobSource,
|
||||||
|
BufferTarget,
|
||||||
|
canEncodeAudio,
|
||||||
|
canEncodeVideo,
|
||||||
|
Conversion,
|
||||||
|
Input,
|
||||||
|
Mp4OutputFormat,
|
||||||
|
Output,
|
||||||
|
type VideoCodec,
|
||||||
|
WebMOutputFormat,
|
||||||
|
} from 'mediabunny'
|
||||||
|
|
||||||
import {VIDEO_MAX_SIZE} from '#/lib/constants'
|
import {VIDEO_MAX_SIZE} from '#/lib/constants'
|
||||||
import {VideoTooLargeError} from '#/lib/media/video/errors'
|
import {VideoTooLargeError} from '#/lib/media/video/errors'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {hasWebCodecs} from '#/view/com/composer/videos/metadata'
|
||||||
|
import {
|
||||||
|
COMPRESSION_MAX_DIMENSION,
|
||||||
|
COMPRESSION_MIN_SIZE_BYTES,
|
||||||
|
COMPRESSION_TARGET_BITRATE,
|
||||||
|
} from './constants'
|
||||||
import {type CompressedVideo} from './types'
|
import {type CompressedVideo} from './types'
|
||||||
|
|
||||||
// doesn't actually compress, converts to ArrayBuffer
|
// Codecs to try in order of preference
|
||||||
|
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
||||||
|
const VIDEO_CODECS: VideoCodec[] = ['avc', 'hevc', 'vp9', 'vp8']
|
||||||
|
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
asset: ImagePickerAsset,
|
asset: ImagePickerAsset,
|
||||||
_opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {mimeType, base64} = parseDataUrl(asset.uri)
|
const {onProgress, signal} = opts || {}
|
||||||
const blob = base64ToBlob(base64, mimeType)
|
|
||||||
const uri = URL.createObjectURL(blob)
|
|
||||||
|
|
||||||
|
logger.debug('compress: starting', {
|
||||||
|
uri: asset.uri.slice(0, 50),
|
||||||
|
hasWebCodecs: hasWebCodecs(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(asset.uri)
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
const isGif = blob.type === 'image/gif'
|
||||||
|
|
||||||
|
logger.debug('compress: fetched blob', {
|
||||||
|
size: blob.size,
|
||||||
|
mimeType: blob.type,
|
||||||
|
isGif,
|
||||||
|
minSizeForCompression: COMPRESSION_MIN_SIZE_BYTES,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try MediaBunny compression if WebCodecs is available and file is large enough
|
||||||
|
// Skip GIFs - MediaBunny doesn't support them
|
||||||
|
if (hasWebCodecs() && blob.size >= COMPRESSION_MIN_SIZE_BYTES && !isGif) {
|
||||||
|
try {
|
||||||
|
return await doCompression(blob, asset.uri, {onProgress, signal})
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('compress: MediaBunny compression failed, using original', {
|
||||||
|
safeMessage: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug('compress: skipping compression', {
|
||||||
|
hasWebCodecs: hasWebCodecs(),
|
||||||
|
blobSize: blob.size,
|
||||||
|
minSize: COMPRESSION_MIN_SIZE_BYTES,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// No compression path - just return the blob as-is
|
||||||
if (blob.size > VIDEO_MAX_SIZE) {
|
if (blob.size > VIDEO_MAX_SIZE) {
|
||||||
throw new VideoTooLargeError()
|
throw new VideoTooLargeError()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
uri: asset.uri,
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
uri,
|
|
||||||
bytes: await blob.arrayBuffer(),
|
bytes: await blob.arrayBuffer(),
|
||||||
|
mimeType: blob.type || 'video/mp4',
|
||||||
|
// Telemetry: this path runs when WebCodecs is unavailable, the file is
|
||||||
|
// under the size threshold, the source is a GIF, or mediabunny threw.
|
||||||
|
// All of those mean we didn't actually compress.
|
||||||
|
passthroughReason: 'web-passthrough',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findEncodableVideoCodec(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Promise<{codec: VideoCodec; useWebM: boolean} | null> {
|
||||||
|
for (const codec of VIDEO_CODECS) {
|
||||||
|
const canEncode = await canEncodeVideo(codec, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
bitrate: COMPRESSION_TARGET_BITRATE,
|
||||||
|
})
|
||||||
|
logger.debug('compress: checking video codec', {
|
||||||
|
codec,
|
||||||
|
canEncode,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
if (canEncode) {
|
||||||
|
// vp8/vp9 need WebM container, others use MP4
|
||||||
|
const useWebM = codec === 'vp8' || codec === 'vp9'
|
||||||
|
return {codec, useWebM}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio codecs to try - aac for MP4, opus for WebM
|
||||||
|
const AUDIO_CODECS_MP4: AudioCodec[] = ['aac']
|
||||||
|
const AUDIO_CODECS_WEBM: AudioCodec[] = ['opus', 'vorbis']
|
||||||
|
|
||||||
|
async function findEncodableAudioCodec(
|
||||||
|
audioTrack: Awaited<ReturnType<Input['getPrimaryAudioTrack']>>,
|
||||||
|
useWebM: boolean,
|
||||||
|
): Promise<{codec: AudioCodec} | null> {
|
||||||
|
if (!audioTrack) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if we can decode the source audio
|
||||||
|
const canDecodeSource = await audioTrack.canDecode()
|
||||||
|
logger.debug('compress: checking audio source', {
|
||||||
|
sourceCodec: audioTrack.codec,
|
||||||
|
canDecode: canDecodeSource,
|
||||||
|
channels: audioTrack.numberOfChannels,
|
||||||
|
sampleRate: audioTrack.sampleRate,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!canDecodeSource) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const codecsToTry = useWebM ? AUDIO_CODECS_WEBM : AUDIO_CODECS_MP4
|
||||||
|
|
||||||
|
for (const codec of codecsToTry) {
|
||||||
|
const canEncode = await canEncodeAudio(codec, {
|
||||||
|
numberOfChannels: audioTrack.numberOfChannels,
|
||||||
|
sampleRate: audioTrack.sampleRate,
|
||||||
|
})
|
||||||
|
logger.debug('compress: checking audio encode codec', {
|
||||||
|
codec,
|
||||||
|
canEncode,
|
||||||
|
})
|
||||||
|
if (canEncode) {
|
||||||
|
return {codec}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCompression(
|
||||||
|
blob: Blob,
|
||||||
|
originalUri: string,
|
||||||
|
opts: {
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
},
|
||||||
|
): Promise<CompressedVideo> {
|
||||||
|
const {onProgress, signal} = opts
|
||||||
|
|
||||||
|
const input = new Input({
|
||||||
|
source: new BlobSource(blob),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get video track to determine dimensions for codec check
|
||||||
|
const videoTrack = await input.getPrimaryVideoTrack()
|
||||||
|
if (!videoTrack) {
|
||||||
|
input.dispose()
|
||||||
|
throw new Error('No video track found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get audio track to check if we can encode it
|
||||||
|
const audioTrack = await input.getPrimaryAudioTrack()
|
||||||
|
|
||||||
|
const {width, height} = calculateDimensions(
|
||||||
|
videoTrack.displayWidth,
|
||||||
|
videoTrack.displayHeight,
|
||||||
|
COMPRESSION_MAX_DIMENSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug('compress: video dimensions', {
|
||||||
|
original: {
|
||||||
|
width: videoTrack.displayWidth,
|
||||||
|
height: videoTrack.displayHeight,
|
||||||
|
},
|
||||||
|
target: {width, height},
|
||||||
|
audioCodec: audioTrack?.codec,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find a video codec we can encode with
|
||||||
|
const codecInfo = await findEncodableVideoCodec(width, height)
|
||||||
|
if (!codecInfo) {
|
||||||
|
input.dispose()
|
||||||
|
throw new Error('No supported video codec available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we can encode the audio
|
||||||
|
const audioCodecInfo = await findEncodableAudioCodec(
|
||||||
|
audioTrack,
|
||||||
|
codecInfo.useWebM,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug('compress: using codecs', {
|
||||||
|
video: codecInfo.codec,
|
||||||
|
audio: audioCodecInfo?.codec ?? 'none',
|
||||||
|
useWebM: codecInfo.useWebM,
|
||||||
|
})
|
||||||
|
|
||||||
|
const target = new BufferTarget()
|
||||||
|
const output = new Output({
|
||||||
|
format: codecInfo.useWebM ? new WebMOutputFormat() : new Mp4OutputFormat(),
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If we have audio but can't encode it, bail out and use the original
|
||||||
|
if (audioTrack && !audioCodecInfo) {
|
||||||
|
input.dispose()
|
||||||
|
throw new Error(
|
||||||
|
`Cannot encode audio codec: ${audioTrack.codec ?? 'unknown'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversion = await Conversion.init({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
video: {
|
||||||
|
codec: codecInfo.codec,
|
||||||
|
bitrate: COMPRESSION_TARGET_BITRATE,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fit: 'contain',
|
||||||
|
},
|
||||||
|
audio: audioCodecInfo ? {codec: audioCodecInfo.codec} : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
conversion.onProgress = onProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
logger.debug('compress: cancelled')
|
||||||
|
void conversion.cancel()
|
||||||
|
},
|
||||||
|
{once: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('compress: starting conversion')
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conversion.execute()
|
||||||
|
} finally {
|
||||||
|
input.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - startTime
|
||||||
|
const bytes = target.buffer
|
||||||
|
|
||||||
|
if (!bytes) {
|
||||||
|
// mediabunny's BufferTarget reports a null buffer after a successful
|
||||||
|
// execute(). Should not happen in normal use; recoverable here because
|
||||||
|
// the outer compressVideo() catches and falls back to the original blob,
|
||||||
|
// but worth flagging in Sentry so we can chase the root cause.
|
||||||
|
const err = new Error('Compression produced empty output')
|
||||||
|
logger.error(err, {})
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = codecInfo.useWebM ? 'video/webm' : 'video/mp4'
|
||||||
|
|
||||||
|
const savedBytes = blob.size - bytes.byteLength
|
||||||
|
const savedPercent = ((savedBytes / blob.size) * 100).toFixed(1)
|
||||||
|
|
||||||
|
logger.debug('compress: completed', {
|
||||||
|
from: blob.type,
|
||||||
|
to: mimeType,
|
||||||
|
originalSize: blob.size,
|
||||||
|
compressedSize: bytes.byteLength,
|
||||||
|
savedBytes,
|
||||||
|
savedPercent: `${savedPercent}%`,
|
||||||
|
elapsedMs: Math.round(elapsed),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (bytes.byteLength > VIDEO_MAX_SIZE) {
|
||||||
|
throw new VideoTooLargeError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: originalUri,
|
||||||
|
size: bytes.byteLength,
|
||||||
|
bytes,
|
||||||
mimeType,
|
mimeType,
|
||||||
// web today is pure pass-through (no WebCodecs); see frontend spec §A1.
|
// web today is pure pass-through (no WebCodecs); see frontend spec §A1.
|
||||||
passthroughReason: 'web-passthrough',
|
passthroughReason: 'web-passthrough',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDataUrl(dataUrl: string) {
|
function calculateDimensions(
|
||||||
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
|
width: number,
|
||||||
if (!mimeType || !base64) {
|
height: number,
|
||||||
throw new Error('Invalid data URL')
|
maxDimension: number,
|
||||||
}
|
): {width: number; height: number} {
|
||||||
return {mimeType, base64}
|
const maxSide = Math.max(width, height)
|
||||||
|
if (maxSide <= maxDimension) {
|
||||||
|
return {width, height}
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToBlob(base64: string, mimeType: string) {
|
const scale = maxDimension / maxSide
|
||||||
const byteCharacters = atob(base64)
|
return {
|
||||||
const byteArrays = []
|
width: Math.round(width * scale),
|
||||||
|
height: Math.round(height * scale),
|
||||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
|
||||||
const slice = byteCharacters.slice(offset, offset + 512)
|
|
||||||
const byteNumbers = new Array(slice.length)
|
|
||||||
|
|
||||||
for (let i = 0; i < slice.length; i++) {
|
|
||||||
byteNumbers[i] = slice.charCodeAt(i)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const byteArray = new Uint8Array(byteNumbers)
|
|
||||||
byteArrays.push(byteArray)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Blob(byteArrays, {type: mimeType})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
// Target encode bitrate when we do compress. Matches the web path.
|
// Shared compression knobs. Mirrored between native (compress.ts) and web
|
||||||
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3mbps
|
// (compress.web.ts) so both platforms produce videos with the same target.
|
||||||
// Source files at or under this bitrate skip compression (paired with
|
|
||||||
// COMPRESSION_MAX_DIMENSION). Slightly above the encode target so we
|
// Target encode bitrate when we do compress.
|
||||||
// don't re-encode files that are already close to what we'd produce.
|
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
|
||||||
export const COMPRESSION_PASSTHROUGH_BITRATE = 5_000_000 // 5mbps
|
|
||||||
// Output dimension cap when compressing, and skip threshold for source files.
|
// Output dimension cap when compressing, and skip threshold for source files.
|
||||||
export const COMPRESSION_MAX_DIMENSION = 1920
|
export const COMPRESSION_MAX_DIMENSION = 1920
|
||||||
|
// Native only: source files at or under this bitrate skip compression (paired
|
||||||
|
// with COMPRESSION_MAX_DIMENSION). Slightly above the encode target so we
|
||||||
|
// don't re-encode files that are already close to what we'd produce.
|
||||||
|
export const COMPRESSION_PASSTHROUGH_BITRATE = 5_000_000 // 5 Mbps
|
||||||
|
// Web only: files under this size skip compression entirely. Native applies
|
||||||
|
// its own threshold logic inside expo-bluesky-video-compress's probe step.
|
||||||
|
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ msgstr "{0}/{1} {2, plural, one {member} other {members}}"
|
|||||||
|
|
||||||
#. Badge showing the current image position out of the total number of images in a gallery.
|
#. Badge showing the current image position out of the total number of images in a gallery.
|
||||||
#. placeholder {0}: index + 1
|
#. placeholder {0}: index + 1
|
||||||
#: src/components/images/Gallery/index.tsx:526
|
#: src/components/images/Gallery/index.tsx:521
|
||||||
msgctxt "gallery-badge-image-position-numbers"
|
msgctxt "gallery-badge-image-position-numbers"
|
||||||
msgid "{0}/{imageCount}"
|
msgid "{0}/{imageCount}"
|
||||||
msgstr "{0}/{imageCount}"
|
msgstr "{0}/{imageCount}"
|
||||||
@@ -1435,9 +1435,9 @@ msgstr ""
|
|||||||
msgid "Already signed in as @{0}"
|
msgid "Already signed in as @{0}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/images/AutoSizedImage.tsx:204
|
#: src/components/images/AutoSizedImage.tsx:205
|
||||||
#: src/components/images/Gallery/index.tsx:588
|
#: src/components/images/Gallery/index.tsx:585
|
||||||
#: src/components/images/ImageLayoutGridItem.tsx:142
|
#: src/components/images/ImageLayoutGridItem.tsx:135
|
||||||
#: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94
|
#: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94
|
||||||
#: src/view/com/composer/GifAltText.tsx:100
|
#: src/view/com/composer/GifAltText.tsx:100
|
||||||
#: src/view/com/composer/photos/Gallery.tsx:211
|
#: src/view/com/composer/photos/Gallery.tsx:211
|
||||||
@@ -2406,7 +2406,7 @@ msgstr ""
|
|||||||
msgid "Captions & alt text"
|
msgid "Captions & alt text"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/images/Gallery/index.tsx:275
|
#: src/components/images/Gallery/index.tsx:273
|
||||||
msgid "carousel"
|
msgid "carousel"
|
||||||
msgstr "carousel"
|
msgstr "carousel"
|
||||||
|
|
||||||
@@ -6097,12 +6097,12 @@ msgstr ""
|
|||||||
msgid "If you're trying to change your handle or email, do so before you deactivate."
|
msgid "If you're trying to change your handle or email, do so before you deactivate."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/images/ImageLayoutGridItem.tsx:96
|
#: src/components/images/ImageLayoutGridItem.tsx:91
|
||||||
msgid "Image"
|
msgid "Image"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: index + 1
|
#. placeholder {0}: index + 1
|
||||||
#: src/components/images/Gallery/index.tsx:459
|
#: src/components/images/Gallery/index.tsx:454
|
||||||
msgid "Image {0}"
|
msgid "Image {0}"
|
||||||
msgstr "Image {0}"
|
msgstr "Image {0}"
|
||||||
|
|
||||||
@@ -6113,7 +6113,7 @@ msgid "Image {0} of {1}"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: index + 1
|
#. placeholder {0}: index + 1
|
||||||
#: src/components/images/Gallery/index.tsx:444
|
#: src/components/images/Gallery/index.tsx:439
|
||||||
msgid "Image {0} of {imageCount}"
|
msgid "Image {0} of {imageCount}"
|
||||||
msgstr "Image {0} of {imageCount}"
|
msgstr "Image {0} of {imageCount}"
|
||||||
|
|
||||||
@@ -6128,7 +6128,7 @@ msgid "Image cache cleared, freed {0}"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: images.length
|
#. placeholder {0}: images.length
|
||||||
#: src/components/images/Gallery/index.tsx:276
|
#: src/components/images/Gallery/index.tsx:274
|
||||||
msgid "Image gallery, {0} images"
|
msgid "Image gallery, {0} images"
|
||||||
msgstr "Image gallery, {0} images"
|
msgstr "Image gallery, {0} images"
|
||||||
|
|
||||||
@@ -8324,7 +8324,7 @@ msgstr ""
|
|||||||
msgid "Opens flow to sign in to your existing Bluesky account"
|
msgid "Opens flow to sign in to your existing Bluesky account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/images/Gallery/index.tsx:460
|
#: src/components/images/Gallery/index.tsx:455
|
||||||
msgid "Opens full image"
|
msgid "Opens full image"
|
||||||
msgstr "Opens full image"
|
msgstr "Opens full image"
|
||||||
|
|
||||||
@@ -10975,7 +10975,7 @@ msgstr ""
|
|||||||
msgid "Skip to next step"
|
msgid "Skip to next step"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/images/Gallery/index.tsx:443
|
#: src/components/images/Gallery/index.tsx:438
|
||||||
msgid "slide"
|
msgid "slide"
|
||||||
msgstr "slide"
|
msgstr "slide"
|
||||||
|
|
||||||
@@ -13259,8 +13259,8 @@ msgstr ""
|
|||||||
msgid "View your verifications"
|
msgid "View your verifications"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/images/AutoSizedImage.tsx:221
|
#: src/components/images/AutoSizedImage.tsx:222
|
||||||
#: src/components/images/AutoSizedImage.tsx:253
|
#: src/components/images/AutoSizedImage.tsx:254
|
||||||
msgid "Views full image"
|
msgid "Views full image"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ import {
|
|||||||
type VideoState,
|
type VideoState,
|
||||||
} from './state/video'
|
} from './state/video'
|
||||||
import {type TextInputRef} from './text-input/TextInput.types'
|
import {type TextInputRef} from './text-input/TextInput.types'
|
||||||
import {getVideoMetadata} from './videos/pickVideo'
|
import {getVideoMetadata} from './videos/metadata'
|
||||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||||
|
|
||||||
type CancelRef = {
|
type CancelRef = {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Ima
|
|||||||
import * as toast from '#/components/Toast'
|
import * as toast from '#/components/Toast'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {isAnimatedGif} from './videos/isAnimatedGif'
|
import {isAnimatedGif} from './videos/isAnimatedGif'
|
||||||
|
import {hasWebCodecs} from './videos/metadata'
|
||||||
|
|
||||||
export type SelectMediaButtonProps = {
|
export type SelectMediaButtonProps = {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -291,9 +292,15 @@ async function processImagePickerAssets(
|
|||||||
/*
|
/*
|
||||||
* Filesize appears to be stable across all platforms, so we can use it
|
* Filesize appears to be stable across all platforms, so we can use it
|
||||||
* to filter out large files on web. On native, we compress these anyway,
|
* to filter out large files on web. On native, we compress these anyway,
|
||||||
* so we only check on web.
|
* so we only check on web. On web, we can reject early if the browser
|
||||||
|
* doesn't support WebCodecs.
|
||||||
*/
|
*/
|
||||||
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
if (
|
||||||
|
IS_WEB &&
|
||||||
|
!hasWebCodecs() &&
|
||||||
|
asset.fileSize &&
|
||||||
|
asset.fileSize > VIDEO_MAX_SIZE
|
||||||
|
) {
|
||||||
errors.add(SelectedAssetError.FileTooBig)
|
errors.add(SelectedAssetError.FileTooBig)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -309,8 +316,7 @@ async function processImagePickerAssets(
|
|||||||
if (type === 'gif') {
|
if (type === 'gif') {
|
||||||
/*
|
/*
|
||||||
* Filesize appears to be stable across all platforms, so we can use it
|
* Filesize appears to be stable across all platforms, so we can use it
|
||||||
* to filter out large files on web. On native, we compress GIFs as
|
* to filter out large files. We can't compress GIFs on either platform.
|
||||||
* videos anyway, so we only check on web.
|
|
||||||
*/
|
*/
|
||||||
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||||
errors.add(SelectedAssetError.FileTooBig)
|
errors.add(SelectedAssetError.FileTooBig)
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
|
import {atoms as a, flatten} from '#/alf'
|
||||||
|
|
||||||
export function clearThumbnailCache() {
|
export function clearThumbnailCache() {
|
||||||
// no-op
|
// no-op on web
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoTranscodeBackdrop() {
|
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||||
return null
|
return (
|
||||||
|
<video
|
||||||
|
src={uri}
|
||||||
|
style={flatten([
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.h_full,
|
||||||
|
a.w_full,
|
||||||
|
{
|
||||||
|
objectFit: 'cover',
|
||||||
|
filter: 'blur(15px)',
|
||||||
|
transform: 'scale(1.1)', // hide blur edges
|
||||||
|
},
|
||||||
|
])}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {type ImagePickerAsset} from 'expo-image-picker'
|
|||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||||
import {IS_WEB} from '#/env'
|
|
||||||
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||||
|
|
||||||
@@ -20,8 +19,6 @@ export function VideoTranscodeProgress({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
if (IS_WEB) return null
|
|
||||||
|
|
||||||
let aspectRatio: number | undefined
|
let aspectRatio: number | undefined
|
||||||
if (asset.width && asset.height) {
|
if (asset.width && asset.height) {
|
||||||
const raw = asset.width / asset.height
|
const raw = asset.width / asset.height
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {getVideoMetaData} from 'react-native-compressor'
|
||||||
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
|
import {extToMime} from '#/lib/media/video/util'
|
||||||
|
|
||||||
|
export async function getVideoMetadata(
|
||||||
|
file: File | string,
|
||||||
|
): Promise<ImagePickerAsset> {
|
||||||
|
if (typeof file !== 'string')
|
||||||
|
throw new Error(
|
||||||
|
'getVideoMetadata was passed a File, when on native it should be a uri',
|
||||||
|
)
|
||||||
|
const metadata = await getVideoMetaData(file)
|
||||||
|
return {
|
||||||
|
uri: file,
|
||||||
|
mimeType: extToMime(metadata.extension),
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height,
|
||||||
|
duration: metadata.duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWebCodecs(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
import {ALL_FORMATS, BlobSource, Input} from 'mediabunny'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
|
||||||
|
export function hasWebCodecs(): boolean {
|
||||||
|
return (
|
||||||
|
typeof VideoEncoder !== 'undefined' && typeof VideoDecoder !== 'undefined'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideoMetadata(
|
||||||
|
file: File | string,
|
||||||
|
): Promise<ImagePickerAsset> {
|
||||||
|
if (typeof file === 'string')
|
||||||
|
throw new Error(
|
||||||
|
'getVideoMetadata was passed a uri, when on web it should be a File',
|
||||||
|
)
|
||||||
|
const blobUrl = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
logger.debug('metadata: starting', {
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
mimeType: file.type,
|
||||||
|
hasWebCodecs: hasWebCodecs(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasWebCodecs()) {
|
||||||
|
try {
|
||||||
|
const result = await getMetadataWithWebCodecs(file, blobUrl)
|
||||||
|
logger.debug('metadata: WebCodecs succeeded', {
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
duration: result.duration,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('metadata: WebCodecs failed, using fallback', {
|
||||||
|
safeMessage: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to old-fashioned browser APIs
|
||||||
|
const result = await getMetadataWithBrowserAPIs(file, blobUrl)
|
||||||
|
logger.debug('metadata: browser API succeeded', {
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
duration: result.duration,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMetadataWithWebCodecs(
|
||||||
|
file: File,
|
||||||
|
blobUrl: string,
|
||||||
|
): Promise<ImagePickerAsset> {
|
||||||
|
const input = new Input({
|
||||||
|
source: new BlobSource(file),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [videoTrack, duration] = await Promise.all([
|
||||||
|
input.getPrimaryVideoTrack(),
|
||||||
|
input.computeDuration(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!videoTrack) {
|
||||||
|
throw new Error('No video track found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: blobUrl,
|
||||||
|
mimeType: file.type,
|
||||||
|
width: videoTrack.displayWidth,
|
||||||
|
height: videoTrack.displayHeight,
|
||||||
|
duration: duration * 1000, // convert seconds to ms
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
input.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMetadataWithBrowserAPIs(
|
||||||
|
file: File,
|
||||||
|
blobUrl: string,
|
||||||
|
): Promise<ImagePickerAsset> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (file.type === 'image/gif') {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({
|
||||||
|
uri: blobUrl,
|
||||||
|
mimeType: 'image/gif',
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
duration: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(blobUrl)
|
||||||
|
reject(new Error('Failed to load GIF'))
|
||||||
|
}
|
||||||
|
img.src = blobUrl
|
||||||
|
} else {
|
||||||
|
const video = document.createElement('video')
|
||||||
|
video.preload = 'metadata'
|
||||||
|
video.src = blobUrl
|
||||||
|
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
resolve({
|
||||||
|
uri: blobUrl,
|
||||||
|
mimeType: file.type,
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
duration: video.duration * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
video.onerror = () => {
|
||||||
|
URL.revokeObjectURL(blobUrl)
|
||||||
|
reject(new Error('Failed to load video metadata'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import {getVideoMetaData} from 'react-native-compressor'
|
|
||||||
import {
|
|
||||||
type ImagePickerAsset,
|
|
||||||
launchImageLibraryAsync,
|
|
||||||
UIImagePickerPreferredAssetRepresentationMode,
|
|
||||||
} from 'expo-image-picker'
|
|
||||||
|
|
||||||
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
|
|
||||||
import {extToMime} from '#/lib/media/video/util'
|
|
||||||
|
|
||||||
export async function pickVideo() {
|
|
||||||
return await launchImageLibraryAsync({
|
|
||||||
exif: false,
|
|
||||||
mediaTypes: ['videos'],
|
|
||||||
quality: 1,
|
|
||||||
legacy: true,
|
|
||||||
preferredAssetRepresentationMode:
|
|
||||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
|
||||||
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets video metadata from a file or uri, depending on the platform
|
|
||||||
*
|
|
||||||
* @param file File on web, uri on native
|
|
||||||
*/
|
|
||||||
export async function getVideoMetadata(
|
|
||||||
file: File | string,
|
|
||||||
): Promise<ImagePickerAsset> {
|
|
||||||
if (typeof file !== 'string')
|
|
||||||
throw new Error(
|
|
||||||
'getVideoMetadata was passed a File, when on native it should be a uri',
|
|
||||||
)
|
|
||||||
const metadata = await getVideoMetaData(file)
|
|
||||||
return {
|
|
||||||
uri: file,
|
|
||||||
mimeType: extToMime(metadata.extension),
|
|
||||||
width: metadata.width,
|
|
||||||
height: metadata.height,
|
|
||||||
duration: metadata.duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import {type ImagePickerAsset, type ImagePickerResult} from 'expo-image-picker'
|
|
||||||
|
|
||||||
import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
|
|
||||||
|
|
||||||
// mostly copied from expo-image-picker and adapted to support gifs
|
|
||||||
// also adds support for reading video metadata
|
|
||||||
|
|
||||||
export async function pickVideo(): Promise<ImagePickerResult> {
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.style.display = 'none'
|
|
||||||
input.setAttribute('type', 'file')
|
|
||||||
// TODO: do we need video/* here? -sfn
|
|
||||||
input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
|
|
||||||
input.setAttribute('id', String(Math.random()))
|
|
||||||
|
|
||||||
document.body.appendChild(input)
|
|
||||||
|
|
||||||
return new Promise(resolve => {
|
|
||||||
input.addEventListener('change', async () => {
|
|
||||||
if (input.files) {
|
|
||||||
const file = input.files[0]
|
|
||||||
resolve({
|
|
||||||
canceled: false,
|
|
||||||
assets: [await getVideoMetadata(file)],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
resolve({canceled: true, assets: null})
|
|
||||||
}
|
|
||||||
document.body.removeChild(input)
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = new MouseEvent('click')
|
|
||||||
input.dispatchEvent(event)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: we're converting to a dataUrl here, and then converting back to an
|
|
||||||
// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
|
|
||||||
// lets us use the ImagePickerAsset type, which the rest of the code expects.
|
|
||||||
// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
|
|
||||||
// instead of a string -sfn
|
|
||||||
export function getVideoMetadata(
|
|
||||||
file: File | string,
|
|
||||||
): Promise<ImagePickerAsset> {
|
|
||||||
if (typeof file === 'string')
|
|
||||||
throw new Error(
|
|
||||||
'getVideoMetadata was passed a uri, when on web it should be a File',
|
|
||||||
)
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
const uri = reader.result as string
|
|
||||||
|
|
||||||
if (file.type === 'image/gif') {
|
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => {
|
|
||||||
resolve({
|
|
||||||
uri,
|
|
||||||
mimeType: 'image/gif',
|
|
||||||
width: img.width,
|
|
||||||
height: img.height,
|
|
||||||
// todo: calculate gif duration. seems possible if you read the bytes
|
|
||||||
// https://codepen.io/Ryman/pen/nZpYwY
|
|
||||||
// for now let's just let the server reject it, since that seems uncommon -sfn
|
|
||||||
duration: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
img.onerror = (_ev, _source, _lineno, _colno, error) => {
|
|
||||||
console.log('Failed to grab GIF metadata', error)
|
|
||||||
reject(new Error('Failed to grab GIF metadata'))
|
|
||||||
}
|
|
||||||
img.src = uri
|
|
||||||
} else {
|
|
||||||
const video = document.createElement('video')
|
|
||||||
const blobUrl = URL.createObjectURL(file)
|
|
||||||
|
|
||||||
video.preload = 'metadata'
|
|
||||||
video.src = blobUrl
|
|
||||||
|
|
||||||
video.onloadedmetadata = () => {
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
resolve({
|
|
||||||
uri,
|
|
||||||
mimeType: file.type,
|
|
||||||
width: video.videoWidth,
|
|
||||||
height: video.videoHeight,
|
|
||||||
// convert seconds to ms
|
|
||||||
duration: video.duration * 1000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
video.onerror = (_ev, _source, _lineno, _colno, error) => {
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
console.log('Failed to grab video metadata', error)
|
|
||||||
reject(new Error('Failed to grab video metadata'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user