diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b080441230..bba5c2a37f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -2672,9 +2672,14 @@ function VideoUploadToolbar({state}: {state: VideoState}) { const t = useTheme() const {t: l} = useLingui() const progress = state.progress + const processingProgress = + state.status === 'processing' ? state.jobStatus?.progress : undefined const shouldRotate = - state.status === 'processing' && (progress === 0 || progress === 1) - let wheelProgress = shouldRotate ? 0.33 : progress + state.status === 'processing' && + (processingProgress === undefined || + processingProgress <= 0 || + processingProgress >= 100) + let wheelProgress = progress const rotate = useDerivedValue(() => { if (shouldRotate) { @@ -2723,7 +2728,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) { break case 'error': text = l`Error` - wheelProgress = 100 + wheelProgress = 1 break case 'done': if (isGif) { diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 13bd71f885..80276aaf06 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -17,6 +17,7 @@ import {uploadVideo} from '#/lib/media/video/upload' import {createVideoAgent} from '#/lib/media/video/util' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {advanceVideoProgress, videoProgressForPhase} from './videoProgress' type CaptionsTrack = {lang: string; file: File} @@ -74,7 +75,7 @@ export type NoVideoState = typeof NO_VIDEO type ErrorState = { status: 'error' - progress: 100 + progress: number abortController: AbortController asset: ImagePickerAsset | null video: CompressedVideo | null @@ -128,7 +129,7 @@ type ProcessingState = { type DoneState = { status: 'done' - progress: 100 + progress: 1 abortController: AbortController asset: ImagePickerAsset video: CompressedVideo @@ -173,7 +174,7 @@ export function videoReducer( if (action.type === 'to_error') { return { status: 'error', - progress: 100, + progress: state.progress, abortController: state.abortController, error: action.error, asset: state.asset ?? null, @@ -187,7 +188,11 @@ export function videoReducer( if (state.status === 'compressing' || state.status === 'uploading') { return { ...state, - progress: action.progress, + progress: advanceVideoProgress( + state.progress, + state.status, + action.progress, + ), } } } else if (action.type === 'update_alt_text') { @@ -204,7 +209,7 @@ export function videoReducer( if (state.status === 'compressing') { return { status: 'uploading', - progress: 0, + progress: videoProgressForPhase('uploading', 0), abortController: state.abortController, asset: state.asset, video: action.video, @@ -218,7 +223,7 @@ export function videoReducer( if (state.status === 'uploading') { return { status: 'processing', - progress: 0, + progress: videoProgressForPhase('processing', 0), abortController: state.abortController, asset: state.asset, video: state.video, @@ -236,7 +241,11 @@ export function videoReducer( jobStatus: action.jobStatus, progress: action.jobStatus.progress !== undefined - ? action.jobStatus.progress / 100 + ? advanceVideoProgress( + state.progress, + 'processing', + action.jobStatus.progress / 100, + ) : state.progress, } } @@ -244,7 +253,7 @@ export function videoReducer( if (state.status === 'processing') { return { status: 'done', - progress: 100, + progress: 1, abortController: state.abortController, asset: state.asset, video: state.video, diff --git a/src/view/com/composer/state/videoProgress.test.ts b/src/view/com/composer/state/videoProgress.test.ts new file mode 100644 index 0000000000..6e7666d9e2 --- /dev/null +++ b/src/view/com/composer/state/videoProgress.test.ts @@ -0,0 +1,24 @@ +import {advanceVideoProgress, videoProgressForPhase} from './videoProgress' + +describe('videoProgressForPhase', () => { + it('maps each phase onto one continuous timeline', () => { + expect(videoProgressForPhase('compressing', 0)).toBe(0) + expect(videoProgressForPhase('compressing', 1)).toBe(0.4) + expect(videoProgressForPhase('uploading', 0)).toBe(0.4) + expect(videoProgressForPhase('uploading', 1)).toBe(0.55) + expect(videoProgressForPhase('processing', 0)).toBe(0.55) + expect(videoProgressForPhase('processing', 1)).toBe(0.95) + }) + + it('clamps invalid phase progress', () => { + expect(videoProgressForPhase('uploading', -1)).toBe(0.4) + expect(videoProgressForPhase('uploading', 2)).toBe(0.55) + }) +}) + +describe('advanceVideoProgress', () => { + it('does not move backwards when a transport retries or falls back', () => { + const progressed = advanceVideoProgress(0.5, 'uploading', 0.8) + expect(advanceVideoProgress(progressed, 'uploading', 0)).toBe(progressed) + }) +}) diff --git a/src/view/com/composer/state/videoProgress.ts b/src/view/com/composer/state/videoProgress.ts new file mode 100644 index 0000000000..e6d4746302 --- /dev/null +++ b/src/view/com/composer/state/videoProgress.ts @@ -0,0 +1,27 @@ +export type VideoProgressPhase = 'compressing' | 'uploading' | 'processing' + +// Keep progress monotonic across the full client pipeline instead of showing +// three separate 0 -> 100 cycles. Processing stops short of complete because +// a server-reported 100% can precede the completed blob becoming available. +const PHASE_RANGES: Record = { + compressing: [0, 0.4], + uploading: [0.4, 0.55], + processing: [0.55, 0.95], +} + +export function videoProgressForPhase( + phase: VideoProgressPhase, + phaseProgress: number, +): number { + const [start, end] = PHASE_RANGES[phase] + const clamped = Math.min(1, Math.max(0, phaseProgress)) + return start + (end - start) * clamped +} + +export function advanceVideoProgress( + currentProgress: number, + phase: VideoProgressPhase, + phaseProgress: number, +): number { + return Math.max(currentProgress, videoProgressForPhase(phase, phaseProgress)) +}