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