Add video upload telemetry events
Instruments the video upload pipeline with a typed event family
(picked, compress{Started,Completed,Skipped,Failed}, upload{Started,
Completed,Failed}, processing{Started,Completed,Failed}, published,
abandoned) plus a parent Sentry span with child spans per phase. Every
event carries an uploadId, engine identifier, and jobId once known.
Fleet visibility into per-phase timings, codec paths, file-size
distributions, and the phase users bail in. No content is captured -
sizes, codecs, dimensions, timings, and error class names only.
Telemetry is created alongside the abort controller in Composer.tsx
and threaded through the composer reducer onto VideoState so
onPressPublish can fire the published event once apilib.post resolves.
The abort signal drives the abandoned event with the phase the user
bailed in.
Engine label distinguishes compression backends - currently
react-native-compressor on native and mediabunny on web - so the
analytics can split metrics by engine when the native rewrite ships.
This commit is contained in:
@@ -1331,4 +1331,103 @@ export type Events = {
|
||||
'invite:followersPromo:press': {}
|
||||
// user dismissed the empty-followers promo banner
|
||||
'invite:followersPromo:dismiss': {}
|
||||
|
||||
// === Video upload funnel (Frontend Spec section D) ===
|
||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||
// session end-to-end) + engine (compression engine id, e.g.
|
||||
// native:react-native-compressor@1.13.0). jobId is added once the server
|
||||
// returns it. Sizes / codecs / dimensions / timings only - never content.
|
||||
'video:upload:picked': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
sourceMimeType?: string
|
||||
sourceBytes?: number
|
||||
sourceDurationMs?: number
|
||||
sourceWidth?: number
|
||||
sourceHeight?: number
|
||||
}
|
||||
'video:upload:compressStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
sourceBytes?: number
|
||||
}
|
||||
'video:upload:compressCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytesIn?: number
|
||||
bytesOut: number
|
||||
outputMimeType: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:compressSkipped': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
reason:
|
||||
| 'gif'
|
||||
| 'below-threshold'
|
||||
| 'no-webcodecs'
|
||||
| 'compress-error-fallback'
|
||||
bytes: number
|
||||
mimeType: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:compressFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:uploadStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytes: number
|
||||
}
|
||||
'video:upload:uploadCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
bytes: number
|
||||
elapsedMs: number
|
||||
throughputBytesPerSec: number
|
||||
}
|
||||
'video:upload:uploadFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytes: number
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:processingStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
}
|
||||
'video:upload:processingCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:processingFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:published': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
// wall-clock from picked to published
|
||||
totalElapsedMs: number
|
||||
}
|
||||
// The event that measures the actual problem: users giving up mid-wait.
|
||||
'video:upload:abandoned': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
phase: 'compress' | 'upload' | 'processing'
|
||||
jobId?: string
|
||||
elapsedInPhaseMs: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
||||
import {type CompressedVideo} from './types'
|
||||
import {extToMime} from './util'
|
||||
|
||||
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
|
||||
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
|
||||
|
||||
export async function compressVideo(
|
||||
file: ImagePickerAsset,
|
||||
@@ -16,20 +16,36 @@ export async function compressVideo(
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts || {}
|
||||
|
||||
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||
file.mimeType as SupportedMimeTypes,
|
||||
)
|
||||
|
||||
if (file.mimeType === 'image/gif') {
|
||||
// let's hope they're small enough that they don't need compression!
|
||||
// this compression library doesn't support gifs
|
||||
// worst case - server rejects them. I think that's fine -sfn
|
||||
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
|
||||
return {
|
||||
uri: file.uri,
|
||||
size: file.fileSize ?? -1,
|
||||
mimeType: 'image/gif',
|
||||
passthroughReason: 'gif',
|
||||
}
|
||||
}
|
||||
|
||||
const minimumFileSizeForCompress = isAcceptableFormat
|
||||
? MIN_SIZE_FOR_COMPRESSION
|
||||
: 0
|
||||
// Pre-check the threshold ourselves so we can label the skip in telemetry.
|
||||
// rnc would do the same skip internally via minimumFileSizeForCompress, but
|
||||
// that path is invisible to us.
|
||||
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||
file.mimeType as SupportedMimeTypes,
|
||||
)
|
||||
if (
|
||||
isAcceptableFormat &&
|
||||
file.fileSize != null &&
|
||||
file.fileSize < MIN_SIZE_FOR_COMPRESSION_BYTES
|
||||
) {
|
||||
return {
|
||||
uri: file.uri,
|
||||
size: file.fileSize,
|
||||
mimeType: file.mimeType ?? 'video/mp4',
|
||||
passthroughReason: 'below-threshold',
|
||||
}
|
||||
}
|
||||
|
||||
const compressed = await Video.compress(
|
||||
file.uri,
|
||||
@@ -37,8 +53,6 @@ export async function compressVideo(
|
||||
compressionMethod: 'manual',
|
||||
bitrate: 3_000_000, // 3mbps
|
||||
maxSize: 1920,
|
||||
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
|
||||
minimumFileSizeForCompress,
|
||||
getCancellationId: id => {
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function compressVideo(
|
||||
const blob = await response.blob()
|
||||
|
||||
const isGif = blob.type === 'image/gif'
|
||||
const hasCodecs = hasWebCodecs()
|
||||
|
||||
logger.debug('compress: fetched blob', {
|
||||
size: blob.size,
|
||||
@@ -57,22 +58,32 @@ export async function compressVideo(
|
||||
|
||||
// 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) {
|
||||
let fallbackReason: NonNullable<CompressedVideo['passthroughReason']> | null =
|
||||
null
|
||||
if (isGif) {
|
||||
fallbackReason = 'gif'
|
||||
} else if (!hasCodecs) {
|
||||
fallbackReason = 'no-webcodecs'
|
||||
} else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) {
|
||||
fallbackReason = 'below-threshold'
|
||||
} else {
|
||||
try {
|
||||
return await doCompression(blob, asset.uri, {onProgress, signal})
|
||||
} catch (e) {
|
||||
logger.warn('compress: MediaBunny compression failed, using original', {
|
||||
safeMessage: e,
|
||||
})
|
||||
fallbackReason = 'compress-error-fallback'
|
||||
}
|
||||
} else {
|
||||
logger.debug('compress: skipping compression', {
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
blobSize: blob.size,
|
||||
minSize: COMPRESSION_MIN_SIZE_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug('compress: skipping compression', {
|
||||
hasWebCodecs: hasCodecs,
|
||||
blobSize: blob.size,
|
||||
minSize: COMPRESSION_MIN_SIZE_BYTES,
|
||||
reason: fallbackReason,
|
||||
})
|
||||
|
||||
// No compression path - just return the blob as-is
|
||||
if (blob.size > VIDEO_MAX_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
@@ -83,6 +94,7 @@ export async function compressVideo(
|
||||
size: blob.size,
|
||||
bytes: await blob.arrayBuffer(),
|
||||
mimeType: blob.type || 'video/mp4',
|
||||
passthroughReason: fallbackReason,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {Sentry} from '#/logger/sentry/lib'
|
||||
import {type Metrics} from '#/analytics/metrics'
|
||||
|
||||
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
|
||||
|
||||
// Identifies the active compression engine. Bumped when the engine swaps.
|
||||
// Versions are intentionally hardcoded so a dependency bump shows up in
|
||||
// analytics as a label change.
|
||||
const COMPRESS_ENGINE =
|
||||
Platform.OS === 'web'
|
||||
? 'web:mediabunny@1.25.3'
|
||||
: 'native:react-native-compressor@1.13.0'
|
||||
|
||||
type Phase = 'compress' | 'upload' | 'processing'
|
||||
|
||||
type SkipReason =
|
||||
| 'gif'
|
||||
| 'below-threshold'
|
||||
| 'no-webcodecs'
|
||||
| 'compress-error-fallback'
|
||||
|
||||
function makeUploadId(): string {
|
||||
const c = (globalThis as {crypto?: {randomUUID?: () => string}}).crypto
|
||||
if (c?.randomUUID) return c.randomUUID()
|
||||
return `up_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`
|
||||
}
|
||||
|
||||
function errorClass(e: unknown): string {
|
||||
if (e instanceof Error) return e.name || 'Error'
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
export type VideoTelemetry = {
|
||||
readonly uploadId: string
|
||||
readonly engine: string
|
||||
picked: () => void
|
||||
compressStarted: () => void
|
||||
compressSkipped: (video: {
|
||||
size: number
|
||||
mimeType: string
|
||||
reason: SkipReason
|
||||
}) => void
|
||||
compressCompleted: (video: {size: number; mimeType: string}) => void
|
||||
compressFailed: (e: unknown) => void
|
||||
uploadStarted: (bytes: number) => void
|
||||
uploadCompleted: (jobId: string) => void
|
||||
uploadFailed: (e: unknown) => void
|
||||
processingStarted: (jobId: string) => void
|
||||
processingCompleted: () => void
|
||||
processingFailed: (e: unknown) => void
|
||||
published: () => void
|
||||
}
|
||||
|
||||
export function createVideoTelemetry({
|
||||
asset,
|
||||
signal,
|
||||
metric,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
signal: AbortSignal
|
||||
metric: MetricFn
|
||||
}): VideoTelemetry {
|
||||
const uploadId = makeUploadId()
|
||||
const engine = COMPRESS_ENGINE
|
||||
const startedAt = Date.now()
|
||||
|
||||
let phase: Phase | undefined
|
||||
let phaseStartedAt = startedAt
|
||||
let jobId: string | undefined
|
||||
let uploadBytes: number | undefined
|
||||
let txnEnded = false
|
||||
let abortBound = true
|
||||
|
||||
// Parent span: full selection->ready arc. Inactive so phase spans can be
|
||||
// attached as children regardless of the current async context.
|
||||
const txn = Sentry.startInactiveSpan({
|
||||
name: 'video.upload',
|
||||
op: 'video.upload',
|
||||
attributes: {
|
||||
uploadId,
|
||||
engine,
|
||||
'video.source.mime': asset.mimeType ?? 'unknown',
|
||||
'video.source.bytes': asset.fileSize ?? 0,
|
||||
'video.source.durationMs': asset.duration ?? 0,
|
||||
'video.source.width': asset.width ?? 0,
|
||||
'video.source.height': asset.height ?? 0,
|
||||
},
|
||||
})
|
||||
|
||||
let phaseSpan: ReturnType<typeof Sentry.startInactiveSpan> | undefined
|
||||
|
||||
function endPhaseSpan() {
|
||||
if (!phaseSpan) return
|
||||
phaseSpan.end()
|
||||
phaseSpan = undefined
|
||||
}
|
||||
|
||||
function enterPhase(next: Phase, spanName: string) {
|
||||
endPhaseSpan()
|
||||
phase = next
|
||||
phaseStartedAt = Date.now()
|
||||
phaseSpan = Sentry.withActiveSpan(txn, () =>
|
||||
Sentry.startInactiveSpan({
|
||||
name: spanName,
|
||||
op: spanName,
|
||||
attributes: {uploadId, engine},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function endTxn(outcome: 'ok' | 'error' | 'cancelled') {
|
||||
if (txnEnded) return
|
||||
txnEnded = true
|
||||
endPhaseSpan()
|
||||
txn.setAttribute('outcome', outcome)
|
||||
txn.end()
|
||||
}
|
||||
|
||||
function detachAbort() {
|
||||
if (!abortBound) return
|
||||
abortBound = false
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
if (phase) {
|
||||
metric('video:upload:abandoned', {
|
||||
uploadId,
|
||||
engine,
|
||||
phase,
|
||||
jobId,
|
||||
elapsedInPhaseMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
}
|
||||
endTxn('cancelled')
|
||||
abortBound = false
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, {once: true})
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
engine,
|
||||
|
||||
picked() {
|
||||
metric('video:upload:picked', {
|
||||
uploadId,
|
||||
engine,
|
||||
sourceMimeType: asset.mimeType,
|
||||
sourceBytes: asset.fileSize,
|
||||
sourceDurationMs: asset.duration ?? undefined,
|
||||
sourceWidth: asset.width,
|
||||
sourceHeight: asset.height,
|
||||
})
|
||||
},
|
||||
|
||||
compressStarted() {
|
||||
enterPhase('compress', 'video.compress')
|
||||
metric('video:upload:compressStarted', {
|
||||
uploadId,
|
||||
engine,
|
||||
sourceBytes: asset.fileSize,
|
||||
})
|
||||
},
|
||||
|
||||
compressSkipped({size, mimeType, reason}) {
|
||||
metric('video:upload:compressSkipped', {
|
||||
uploadId,
|
||||
engine,
|
||||
reason,
|
||||
bytes: size,
|
||||
mimeType,
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
endPhaseSpan()
|
||||
phase = undefined
|
||||
},
|
||||
|
||||
compressCompleted({size, mimeType}) {
|
||||
metric('video:upload:compressCompleted', {
|
||||
uploadId,
|
||||
engine,
|
||||
bytesIn: asset.fileSize,
|
||||
bytesOut: size,
|
||||
outputMimeType: mimeType,
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
endPhaseSpan()
|
||||
phase = undefined
|
||||
},
|
||||
|
||||
compressFailed(e) {
|
||||
metric('video:upload:compressFailed', {
|
||||
uploadId,
|
||||
engine,
|
||||
errorClass: errorClass(e),
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
endTxn('error')
|
||||
detachAbort()
|
||||
},
|
||||
|
||||
uploadStarted(bytes) {
|
||||
uploadBytes = bytes
|
||||
enterPhase('upload', 'video.upload.transfer')
|
||||
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
|
||||
},
|
||||
|
||||
uploadCompleted(id) {
|
||||
jobId = id
|
||||
const elapsedMs = Date.now() - phaseStartedAt
|
||||
const bytes = uploadBytes ?? 0
|
||||
metric('video:upload:uploadCompleted', {
|
||||
uploadId,
|
||||
engine,
|
||||
jobId: id,
|
||||
bytes,
|
||||
elapsedMs,
|
||||
throughputBytesPerSec:
|
||||
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
|
||||
})
|
||||
endPhaseSpan()
|
||||
phase = undefined
|
||||
},
|
||||
|
||||
uploadFailed(e) {
|
||||
metric('video:upload:uploadFailed', {
|
||||
uploadId,
|
||||
engine,
|
||||
bytes: uploadBytes ?? 0,
|
||||
errorClass: errorClass(e),
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
endTxn('error')
|
||||
detachAbort()
|
||||
},
|
||||
|
||||
processingStarted(id) {
|
||||
jobId = id
|
||||
enterPhase('processing', 'video.processing')
|
||||
metric('video:upload:processingStarted', {uploadId, engine, jobId: id})
|
||||
},
|
||||
|
||||
processingCompleted() {
|
||||
metric('video:upload:processingCompleted', {
|
||||
uploadId,
|
||||
engine,
|
||||
jobId: jobId ?? '',
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
// Upload pipeline is done; publish is a separate user action that
|
||||
// fires its own event. Releases the parent span so its duration
|
||||
// measures upload work, not idle composer time.
|
||||
endTxn('ok')
|
||||
detachAbort()
|
||||
},
|
||||
|
||||
processingFailed(e) {
|
||||
metric('video:upload:processingFailed', {
|
||||
uploadId,
|
||||
engine,
|
||||
jobId: jobId ?? '',
|
||||
errorClass: errorClass(e),
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
})
|
||||
endTxn('error')
|
||||
detachAbort()
|
||||
},
|
||||
|
||||
published() {
|
||||
metric('video:upload:published', {
|
||||
uploadId,
|
||||
engine,
|
||||
jobId: jobId ?? '',
|
||||
totalElapsedMs: Date.now() - startedAt,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,12 @@ export type CompressedVideo = {
|
||||
size: number
|
||||
// web only, can fall back to uri if missing
|
||||
bytes?: ArrayBuffer
|
||||
// Set when the engine returned the input unchanged. Undefined means the
|
||||
// bytes were actually re-encoded. Used by telemetry to split
|
||||
// compressCompleted vs compressSkipped, and to label the skip reason.
|
||||
passthroughReason?:
|
||||
| 'gif'
|
||||
| 'below-threshold'
|
||||
| 'no-webcodecs'
|
||||
| 'compress-error-fallback'
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
} from '#/lib/constants'
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
||||
import {mimeToExt} from '#/lib/media/video/util'
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
@@ -387,6 +388,12 @@ export const ComposePost = ({
|
||||
const selectVideo = useCallback(
|
||||
(postId: string, asset: ImagePickerAsset) => {
|
||||
const abortController = new AbortController()
|
||||
const telemetry = createVideoTelemetry({
|
||||
asset,
|
||||
signal: abortController.signal,
|
||||
metric: ax.metric,
|
||||
})
|
||||
telemetry.picked()
|
||||
composerDispatch({
|
||||
type: 'update_post',
|
||||
postId: postId,
|
||||
@@ -394,6 +401,7 @@ export const ComposePost = ({
|
||||
type: 'embed_add_video',
|
||||
asset,
|
||||
abortController,
|
||||
telemetry,
|
||||
},
|
||||
})
|
||||
void processVideo(
|
||||
@@ -412,9 +420,10 @@ export const ComposePost = ({
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
telemetry,
|
||||
)
|
||||
},
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch, ax.metric],
|
||||
)
|
||||
|
||||
const onInitVideo = useNonReactiveCallback(() => {
|
||||
@@ -494,6 +503,12 @@ export const ComposePost = ({
|
||||
|
||||
// Start video processing using existing flow
|
||||
const abortController = new AbortController()
|
||||
const telemetry = createVideoTelemetry({
|
||||
asset,
|
||||
signal: abortController.signal,
|
||||
metric: ax.metric,
|
||||
})
|
||||
telemetry.picked()
|
||||
composerDispatch({
|
||||
type: 'update_post',
|
||||
postId,
|
||||
@@ -501,6 +516,7 @@ export const ComposePost = ({
|
||||
type: 'embed_add_video',
|
||||
asset,
|
||||
abortController,
|
||||
telemetry,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -559,6 +575,7 @@ export const ComposePost = ({
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
telemetry,
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error('Failed to restore video from draft', {
|
||||
@@ -567,7 +584,7 @@ export const ComposePost = ({
|
||||
})
|
||||
}
|
||||
},
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch, ax.metric],
|
||||
)
|
||||
|
||||
const handleSelectDraft = useCallback(
|
||||
@@ -979,6 +996,15 @@ export const ComposePost = ({
|
||||
})
|
||||
).uris[0]
|
||||
|
||||
// Fire published event for every video that made it into the post.
|
||||
// The status guard upstream ensures each video.telemetry is present and
|
||||
// processing has completed by this point.
|
||||
for (const post of filteredThread.posts) {
|
||||
if (post.embed.media?.type === 'video') {
|
||||
post.embed.media.video.telemetry?.published()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Wait for app view to have received the post(s). If this fails, it's
|
||||
* ok, because the post _was_ actually published above.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||
import {type SelfLabel} from '#/lib/moderation'
|
||||
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||
@@ -88,6 +89,7 @@ export type PostAction =
|
||||
type: 'embed_add_video'
|
||||
asset: ImagePickerAsset
|
||||
abortController: AbortController
|
||||
telemetry: VideoTelemetry
|
||||
}
|
||||
| {type: 'embed_remove_video'}
|
||||
| {type: 'embed_update_video'; videoAction: VideoAction}
|
||||
@@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
||||
if (!prevMedia) {
|
||||
nextMedia = {
|
||||
type: 'video',
|
||||
video: createVideoState(action.asset, action.abortController),
|
||||
video: createVideoState(
|
||||
action.asset,
|
||||
action.abortController,
|
||||
action.telemetry,
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UploadLimitError,
|
||||
VideoTooLargeError,
|
||||
} from '#/lib/media/video/errors'
|
||||
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {uploadVideo} from '#/lib/media/video/upload'
|
||||
import {createVideoAgent} from '#/lib/media/video/util'
|
||||
@@ -64,6 +65,7 @@ export const NO_VIDEO = Object.freeze({
|
||||
video: undefined,
|
||||
jobId: undefined,
|
||||
pendingPublish: undefined,
|
||||
telemetry: undefined,
|
||||
altText: '',
|
||||
captions: [],
|
||||
})
|
||||
@@ -79,6 +81,7 @@ type ErrorState = {
|
||||
jobId: string | null
|
||||
error: string
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -91,6 +94,7 @@ type CompressingState = {
|
||||
video?: undefined
|
||||
jobId?: undefined
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -103,6 +107,7 @@ type UploadingState = {
|
||||
video: CompressedVideo
|
||||
jobId?: undefined
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -116,6 +121,7 @@ type ProcessingState = {
|
||||
jobId: string
|
||||
jobStatus: AppBskyVideoDefs.JobStatus | null
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -128,6 +134,7 @@ type DoneState = {
|
||||
video: CompressedVideo
|
||||
jobId?: undefined
|
||||
pendingPublish: {blobRef: BlobRef}
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -142,12 +149,14 @@ export type VideoState =
|
||||
export function createVideoState(
|
||||
asset: ImagePickerAsset,
|
||||
abortController: AbortController,
|
||||
telemetry: VideoTelemetry,
|
||||
): CompressingState {
|
||||
return {
|
||||
status: 'compressing',
|
||||
progress: 0,
|
||||
abortController,
|
||||
asset,
|
||||
telemetry,
|
||||
altText: '',
|
||||
captions: [],
|
||||
}
|
||||
@@ -170,6 +179,7 @@ export function videoReducer(
|
||||
asset: state.asset ?? null,
|
||||
video: state.video ?? null,
|
||||
jobId: state.jobId ?? null,
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -198,6 +208,7 @@ export function videoReducer(
|
||||
abortController: state.abortController,
|
||||
asset: state.asset,
|
||||
video: action.video,
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -213,6 +224,7 @@ export function videoReducer(
|
||||
video: state.video,
|
||||
jobId: action.jobId,
|
||||
jobStatus: null,
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -239,6 +251,7 @@ export function videoReducer(
|
||||
pendingPublish: {
|
||||
blobRef: action.blobRef,
|
||||
},
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -265,9 +278,11 @@ export async function processVideo(
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
i18n: I18n,
|
||||
telemetry: VideoTelemetry,
|
||||
) {
|
||||
let video: CompressedVideo | undefined
|
||||
try {
|
||||
telemetry.compressStarted()
|
||||
video = await compressVideo(asset, {
|
||||
onProgress: num => {
|
||||
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||
@@ -277,6 +292,7 @@ export async function processVideo(
|
||||
} catch (e) {
|
||||
const message = getCompressErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
telemetry.compressFailed(e)
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: message,
|
||||
@@ -285,6 +301,15 @@ export async function processVideo(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (video.passthroughReason) {
|
||||
telemetry.compressSkipped({
|
||||
size: video.size,
|
||||
mimeType: video.mimeType,
|
||||
reason: video.passthroughReason,
|
||||
})
|
||||
} else {
|
||||
telemetry.compressCompleted({size: video.size, mimeType: video.mimeType})
|
||||
}
|
||||
dispatch({
|
||||
type: 'compressing_to_uploading',
|
||||
video,
|
||||
@@ -293,6 +318,7 @@ export async function processVideo(
|
||||
|
||||
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
|
||||
try {
|
||||
telemetry.uploadStarted(video.size)
|
||||
uploadResponse = await uploadVideo({
|
||||
video,
|
||||
agent,
|
||||
@@ -306,6 +332,7 @@ export async function processVideo(
|
||||
} catch (e) {
|
||||
const message = getUploadErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
telemetry.uploadFailed(e)
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: message,
|
||||
@@ -316,6 +343,8 @@ export async function processVideo(
|
||||
}
|
||||
|
||||
const jobId = uploadResponse.jobId
|
||||
telemetry.uploadCompleted(jobId)
|
||||
telemetry.processingStarted(jobId)
|
||||
dispatch({
|
||||
type: 'uploading_to_processing',
|
||||
jobId,
|
||||
@@ -354,6 +383,7 @@ export async function processVideo(
|
||||
}
|
||||
|
||||
logger.error('Error processing video', {safeMessage: e})
|
||||
telemetry.processingFailed(e)
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: i18n._(msg`Video failed to process`),
|
||||
@@ -363,6 +393,7 @@ export async function processVideo(
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
telemetry.processingCompleted()
|
||||
dispatch({
|
||||
type: 'to_done',
|
||||
blobRef: blob,
|
||||
|
||||
Reference in New Issue
Block a user