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