mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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}