mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at samuel/fancy-queue 449 lines 11 kB view raw
1import {ImagePickerAsset} from 'expo-image-picker' 2import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api' 3import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' 4import {I18n} from '@lingui/core' 5import {msg} from '@lingui/macro' 6 7import {AbortError} from '#/lib/async/cancelable' 8import {compressVideo} from '#/lib/media/video/compress' 9import { 10 ServerError, 11 UploadLimitError, 12 VideoTooLargeError, 13} from '#/lib/media/video/errors' 14import {CompressedVideo} from '#/lib/media/video/types' 15import {uploadVideo} from '#/lib/media/video/upload' 16import {createVideoAgent} from '#/lib/media/video/util' 17import {logger} from '#/logger' 18 19type CaptionsTrack = {lang: string; file: File} 20 21export type VideoAction = 22 | { 23 type: 'compressing_to_uploading' 24 video: CompressedVideo 25 signal: AbortSignal 26 } 27 | { 28 type: 'uploading_to_processing' 29 jobId: string 30 signal: AbortSignal 31 } 32 | {type: 'to_error'; error: string; signal: AbortSignal} 33 | { 34 type: 'to_done' 35 blobRef: BlobRef 36 signal: AbortSignal 37 } 38 | {type: 'update_progress'; progress: number; signal: AbortSignal} 39 | { 40 type: 'update_dimensions' 41 width: number 42 height: number 43 signal: AbortSignal 44 } 45 | { 46 type: 'update_alt_text' 47 altText: string 48 signal: AbortSignal 49 } 50 | { 51 type: 'update_captions' 52 updater: (prev: CaptionsTrack[]) => CaptionsTrack[] 53 signal: AbortSignal 54 } 55 | { 56 type: 'update_job_status' 57 jobStatus: AppBskyVideoDefs.JobStatus 58 signal: AbortSignal 59 } 60 61const noopController = new AbortController() 62noopController.abort() 63 64export const NO_VIDEO = Object.freeze({ 65 status: 'idle', 66 progress: 0, 67 abortController: noopController, 68 asset: undefined, 69 video: undefined, 70 jobId: undefined, 71 pendingPublish: undefined, 72 altText: '', 73 captions: [], 74}) 75 76export type NoVideoState = typeof NO_VIDEO 77 78type ErrorState = { 79 status: 'error' 80 progress: 100 81 abortController: AbortController 82 asset: ImagePickerAsset | null 83 video: CompressedVideo | null 84 jobId: string | null 85 error: string 86 pendingPublish?: undefined 87 altText: string 88 captions: CaptionsTrack[] 89} 90 91type CompressingState = { 92 status: 'compressing' 93 progress: number 94 abortController: AbortController 95 asset: ImagePickerAsset 96 video?: undefined 97 jobId?: undefined 98 pendingPublish?: undefined 99 altText: string 100 captions: CaptionsTrack[] 101} 102 103type UploadingState = { 104 status: 'uploading' 105 progress: number 106 abortController: AbortController 107 asset: ImagePickerAsset 108 video: CompressedVideo 109 jobId?: undefined 110 pendingPublish?: undefined 111 altText: string 112 captions: CaptionsTrack[] 113} 114 115type ProcessingState = { 116 status: 'processing' 117 progress: number 118 abortController: AbortController 119 asset: ImagePickerAsset 120 video: CompressedVideo 121 jobId: string 122 jobStatus: AppBskyVideoDefs.JobStatus | null 123 pendingPublish?: undefined 124 altText: string 125 captions: CaptionsTrack[] 126} 127 128type DoneState = { 129 status: 'done' 130 progress: 100 131 abortController: AbortController 132 asset: ImagePickerAsset 133 video: CompressedVideo 134 jobId?: undefined 135 pendingPublish: {blobRef: BlobRef} 136 altText: string 137 captions: CaptionsTrack[] 138} 139 140export type VideoState = 141 | ErrorState 142 | CompressingState 143 | UploadingState 144 | ProcessingState 145 | DoneState 146 147export function createVideoState( 148 asset: ImagePickerAsset, 149 abortController: AbortController, 150): CompressingState { 151 return { 152 status: 'compressing', 153 progress: 0, 154 abortController, 155 asset, 156 altText: '', 157 captions: [], 158 } 159} 160 161export function videoReducer( 162 state: VideoState, 163 action: VideoAction, 164): VideoState { 165 if (action.signal.aborted || action.signal !== state.abortController.signal) { 166 // This action is stale and the process that spawned it is no longer relevant. 167 return state 168 } 169 if (action.type === 'to_error') { 170 return { 171 status: 'error', 172 progress: 100, 173 abortController: state.abortController, 174 error: action.error, 175 asset: state.asset ?? null, 176 video: state.video ?? null, 177 jobId: state.jobId ?? null, 178 altText: state.altText, 179 captions: state.captions, 180 } 181 } else if (action.type === 'update_progress') { 182 if (state.status === 'compressing' || state.status === 'uploading') { 183 return { 184 ...state, 185 progress: action.progress, 186 } 187 } 188 } else if (action.type === 'update_dimensions') { 189 if (state.asset) { 190 return { 191 ...state, 192 asset: {...state.asset, width: action.width, height: action.height}, 193 } 194 } 195 } else if (action.type === 'update_alt_text') { 196 return { 197 ...state, 198 altText: action.altText, 199 } 200 } else if (action.type === 'update_captions') { 201 return { 202 ...state, 203 captions: action.updater(state.captions), 204 } 205 } else if (action.type === 'compressing_to_uploading') { 206 if (state.status === 'compressing') { 207 return { 208 status: 'uploading', 209 progress: 0, 210 abortController: state.abortController, 211 asset: state.asset, 212 video: action.video, 213 altText: state.altText, 214 captions: state.captions, 215 } 216 } 217 return state 218 } else if (action.type === 'uploading_to_processing') { 219 if (state.status === 'uploading') { 220 return { 221 status: 'processing', 222 progress: 0, 223 abortController: state.abortController, 224 asset: state.asset, 225 video: state.video, 226 jobId: action.jobId, 227 jobStatus: null, 228 altText: state.altText, 229 captions: state.captions, 230 } 231 } 232 } else if (action.type === 'update_job_status') { 233 if (state.status === 'processing') { 234 return { 235 ...state, 236 jobStatus: action.jobStatus, 237 progress: 238 action.jobStatus.progress !== undefined 239 ? action.jobStatus.progress / 100 240 : state.progress, 241 } 242 } 243 } else if (action.type === 'to_done') { 244 if (state.status === 'processing') { 245 return { 246 status: 'done', 247 progress: 100, 248 abortController: state.abortController, 249 asset: state.asset, 250 video: state.video, 251 pendingPublish: { 252 blobRef: action.blobRef, 253 }, 254 altText: state.altText, 255 captions: state.captions, 256 } 257 } 258 } 259 console.error( 260 'Unexpected video action (' + 261 action.type + 262 ') while in ' + 263 state.status + 264 ' state', 265 ) 266 return state 267} 268 269function trunc2dp(num: number) { 270 return Math.trunc(num * 100) / 100 271} 272 273export async function processVideo( 274 asset: ImagePickerAsset, 275 dispatch: (action: VideoAction) => void, 276 agent: BskyAgent, 277 did: string, 278 signal: AbortSignal, 279 _: I18n['_'], 280) { 281 let video: CompressedVideo | undefined 282 try { 283 video = await compressVideo(asset, { 284 onProgress: num => { 285 dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) 286 }, 287 signal, 288 }) 289 } catch (e) { 290 const message = getCompressErrorMessage(e, _) 291 if (message !== null) { 292 dispatch({ 293 type: 'to_error', 294 error: message, 295 signal, 296 }) 297 } 298 return 299 } 300 dispatch({ 301 type: 'compressing_to_uploading', 302 video, 303 signal, 304 }) 305 306 let uploadResponse: AppBskyVideoDefs.JobStatus | undefined 307 try { 308 uploadResponse = await uploadVideo({ 309 video, 310 agent, 311 did, 312 signal, 313 _, 314 setProgress: p => { 315 dispatch({type: 'update_progress', progress: p, signal}) 316 }, 317 }) 318 } catch (e) { 319 const message = getUploadErrorMessage(e, _) 320 if (message !== null) { 321 dispatch({ 322 type: 'to_error', 323 error: message, 324 signal, 325 }) 326 } 327 return 328 } 329 330 const jobId = uploadResponse.jobId 331 dispatch({ 332 type: 'uploading_to_processing', 333 jobId, 334 signal, 335 }) 336 337 let pollFailures = 0 338 while (true) { 339 if (signal.aborted) { 340 return // Exit async loop 341 } 342 343 const videoAgent = createVideoAgent() 344 let status: JobStatus | undefined 345 let blob: BlobRef | undefined 346 try { 347 const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) 348 status = response.data.jobStatus 349 pollFailures = 0 350 351 if (status.state === 'JOB_STATE_COMPLETED') { 352 blob = status.blob 353 if (!blob) { 354 throw new Error('Job completed, but did not return a blob') 355 } 356 } else if (status.state === 'JOB_STATE_FAILED') { 357 throw new Error(status.error ?? 'Job failed to process') 358 } 359 } catch (e) { 360 if (!status) { 361 pollFailures++ 362 if (pollFailures < 50) { 363 await new Promise(resolve => setTimeout(resolve, 5000)) 364 continue // Continue async loop 365 } 366 } 367 368 logger.error('Error processing video', {safeMessage: e}) 369 dispatch({ 370 type: 'to_error', 371 error: _(msg`Video failed to process`), 372 signal, 373 }) 374 return // Exit async loop 375 } 376 377 if (blob) { 378 dispatch({ 379 type: 'to_done', 380 blobRef: blob, 381 signal, 382 }) 383 } else { 384 dispatch({ 385 type: 'update_job_status', 386 jobStatus: status, 387 signal, 388 }) 389 } 390 391 if ( 392 status.state !== 'JOB_STATE_COMPLETED' && 393 status.state !== 'JOB_STATE_FAILED' 394 ) { 395 await new Promise(resolve => setTimeout(resolve, 1500)) 396 continue // Continue async loop 397 } 398 399 return // Exit async loop 400 } 401} 402 403function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { 404 if (e instanceof AbortError) { 405 return null 406 } 407 if (e instanceof VideoTooLargeError) { 408 return _(msg`The selected video is larger than 50MB.`) 409 } 410 logger.error('Error compressing video', {safeMessage: e}) 411 return _(msg`An error occurred while compressing the video.`) 412} 413 414function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null { 415 if (e instanceof AbortError) { 416 return null 417 } 418 logger.error('Error uploading video', {safeMessage: e}) 419 if (e instanceof ServerError || e instanceof UploadLimitError) { 420 // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 421 switch (e.message) { 422 case 'User is not allowed to upload videos': 423 return _(msg`You are not allowed to upload videos.`) 424 case 'Uploading is disabled at the moment': 425 return _( 426 msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, 427 ) 428 case "Failed to get user's upload stats": 429 return _( 430 msg`We were unable to determine if you are allowed to upload videos. Please try again.`, 431 ) 432 case 'User has exceeded daily upload bytes limit': 433 return _( 434 msg`You've reached your daily limit for video uploads (too many bytes)`, 435 ) 436 case 'User has exceeded daily upload videos limit': 437 return _( 438 msg`You've reached your daily limit for video uploads (too many videos)`, 439 ) 440 case 'Account is not old enough to upload videos': 441 return _( 442 msg`Your account is not yet old enough to upload videos. Please try again later.`, 443 ) 444 default: 445 return e.message 446 } 447 } 448 return _(msg`An error occurred while uploading the video.`) 449}