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/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}