forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}