diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart index 8050931..c54e1b0 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart @@ -1310,9 +1310,12 @@ class FeedRepositoryImpl implements FeedRepository { 'Video upload failed: ${response.statusCode} ${response.body}', ); throw VideoUploadException( - response.statusCode == 413 - ? 'Video is too large to upload.' - : 'Failed to upload video.', + _buildVideoUploadFailureMessage( + fallback: response.statusCode == 413 + ? 'Video is too large to upload.' + : 'Failed to upload video.', + detail: response.body, + ), statusCode: response.statusCode, uploadSizeBytes: videoSizeBytes, limitBytes: maxUploadSizeBytes > 0 ? maxUploadSizeBytes : null, @@ -1337,7 +1340,9 @@ class FeedRepositoryImpl implements FeedRepository { await Future.delayed(const Duration(seconds: 2)); attempts++; if (attempts > maxAttempts) { - throw Exception('Timed out waiting for video processing to finish'); + throw const VideoUploadException( + 'Video processing timed out. Please try again.', + ); } try { @@ -1379,9 +1384,12 @@ class FeedRepositoryImpl implements FeedRepository { 'Too many consecutive polling errors, giving up: $e', error: e, ); - throw Exception( - 'Failed to check video upload status after ' - '$maxConsecutivePollErrors attempts: $e', + throw VideoUploadException( + _buildVideoUploadFailureMessage( + fallback: 'Failed to check video processing status.', + detail: e.toString(), + ), + responseBody: e.toString(), ); } @@ -1393,8 +1401,17 @@ class FeedRepositoryImpl implements FeedRepository { } if (responseData['jobStatus']?['state'] == 'JOB_STATE_FAILED') { - throw Exception( - 'Video upload failed: ${responseData['jobStatus']?['status']}', + final failureMessage = _buildVideoUploadFailureMessage( + fallback: 'Video processing failed.', + detail: responseData['jobStatus'] ?? responseData, + ); + _logger.e( + 'Video processing job failed: $failureMessage', + error: responseData, + ); + throw VideoUploadException( + failureMessage, + responseBody: jsonEncode(responseData), ); } @@ -1975,4 +1992,109 @@ class FeedRepositoryImpl implements FeedRepository { return 'video/mp4'; // Default to mp4 } } + + String _buildVideoUploadFailureMessage({ + required String fallback, + dynamic detail, + }) { + final normalizedDetail = _extractVideoUploadFailureDetail(detail); + if (normalizedDetail == null) { + return fallback; + } + + final normalizedFallback = fallback.trim(); + if (normalizedDetail.toLowerCase() == normalizedFallback.toLowerCase()) { + return normalizedFallback; + } + if (normalizedDetail.toLowerCase().startsWith( + normalizedFallback.toLowerCase(), + )) { + return normalizedDetail; + } + + final separator = normalizedFallback.endsWith('.') ? ' ' : ': '; + return '$normalizedFallback$separator$normalizedDetail'; + } + + String? _extractVideoUploadFailureDetail(dynamic value) { + if (value == null) { + return null; + } + + if (value is String) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(trimmed); + final decodedDetail = _extractVideoUploadFailureDetail(decoded); + if (decodedDetail != null) { + return decodedDetail; + } + } catch (_) { + // Fall back to the raw string when the response is not JSON. + } + + return _sanitizeVideoUploadFailureText(trimmed); + } + + if (value is Map) { + for (final key in const [ + 'message', + 'status', + 'detail', + 'reason', + 'description', + 'error', + ]) { + final nestedDetail = _extractVideoUploadFailureDetail(value[key]); + if (nestedDetail != null) { + return nestedDetail; + } + } + + final jobStatusDetail = _extractVideoUploadFailureDetail( + value['jobStatus'], + ); + if (jobStatusDetail != null) { + return jobStatusDetail; + } + + return null; + } + + if (value is Iterable) { + for (final item in value) { + final itemDetail = _extractVideoUploadFailureDetail(item); + if (itemDetail != null) { + return itemDetail; + } + } + return null; + } + + return _sanitizeVideoUploadFailureText(value.toString()); + } + + String? _sanitizeVideoUploadFailureText(String text) { + final sanitized = text + .replaceFirst( + RegExp(r'^(exception|error):\s*', caseSensitive: false), + '', + ) + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + if (sanitized.isEmpty || + sanitized == '{}' || + sanitized == '[]' || + sanitized.startsWith(' with StoryMentionEditing { static const _storyCanvasSize = Size(1440, 2560); - static const _trimTolerance = Duration(milliseconds: 100); static const _uploadCompressionMinFileSizeBytes = 25 * 1024 * 1024; static const _uploadCompressionBitrate = 3000000; static const _uploadCompressionMaxLongEdge = 1920.0; @@ -574,11 +573,6 @@ class _VideoEditorGroundedPageState extends State sourceVideoPath, ); - if (_canUseOriginalVideo(parameters) && !shouldCompressForUpload) { - _outputPath = sourceVideoPath; - return; - } - final directory = await getTemporaryDirectory(); double overlayVolume = 0; @@ -673,54 +667,6 @@ class _VideoEditorGroundedPageState extends State ); } - bool _canUseOriginalVideo(CompleteParameters parameters) { - if (parameters.layers.isNotEmpty || - parameters.colorFilters.isNotEmpty || - parameters.customAudioTrack != null || - (parameters.blur).abs() > 0.001 || - _hasTrim(parameters) || - _hasRotation(parameters) || - parameters.flipX || - parameters.flipY || - (_proVideoController?.isAudioEnabled == false)) { - return false; - } - - if (!widget.storyMode) { - return !parameters.isTransformed; - } - - return _isStoryExportTransformIdentity(); - } - - bool _hasTrim(CompleteParameters parameters) { - final startTime = - _durationSpan?.start ?? parameters.startTime ?? Duration.zero; - final endTime = - _durationSpan?.end ?? parameters.endTime ?? _videoMetadata.duration; - - return startTime > _trimTolerance || - _videoMetadata.duration - endTime > _trimTolerance; - } - - bool _hasRotation(CompleteParameters parameters) { - final normalizedTurns = parameters.rotateTurns % 4; - return normalizedTurns != 0; - } - - bool _isStoryExportTransformIdentity() { - final coverCrop = _computeStoryCoverCrop(_videoMetadata.resolution); - final sourceWidth = _videoMetadata.resolution.width.round(); - final sourceHeight = _videoMetadata.resolution.height.round(); - - return coverCrop.x == 0 && - coverCrop.y == 0 && - coverCrop.width == sourceWidth && - coverCrop.height == sourceHeight && - _storyTargetWidth(coverCrop) == coverCrop.width && - _storyTargetHeight(coverCrop) == coverCrop.height; - } - int? _targetExportBitrate(ExportTransform? transform) { final sourceBitrate = _videoMetadata.bitrate; if (sourceBitrate <= 0) { diff --git a/lib/src/core/utils/error_messages.dart b/lib/src/core/utils/error_messages.dart index 4a0c829..3c1ae68 100644 --- a/lib/src/core/utils/error_messages.dart +++ b/lib/src/core/utils/error_messages.dart @@ -21,10 +21,16 @@ class ErrorMessages { } return 'This video is too large to upload. Please trim or compress it and try again.'; } + + final detailedMessage = _cleanErrorMessage(error.message); + if (detailedMessage.isNotEmpty) { + return detailedMessage; + } return 'Unable to upload video. Please try again'; } - final errorStr = error.toString().toLowerCase(); + final rawMessage = _cleanErrorMessage(error.toString()); + final errorStr = rawMessage.toLowerCase(); // Upload size errors if (errorStr.contains('413') || @@ -33,6 +39,10 @@ class ErrorMessages { return 'This file is too large to upload. Please trim or compress it and try again.'; } + if (_shouldSurfaceDetailedMessage(errorStr)) { + return rawMessage; + } + // Network errors if (errorStr.contains('socketexception') || errorStr.contains('network') || @@ -151,4 +161,23 @@ class ErrorMessages { } return '$bytes B'; } + + static String _cleanErrorMessage(String message) { + return message + .replaceFirst( + RegExp(r'^(exception|error):\s*', caseSensitive: false), + '', + ) + .trim(); + } + + static bool _shouldSurfaceDetailedMessage(String message) { + return message.startsWith('failed to upload video') || + message.startsWith('video processing failed') || + message.startsWith('failed to check video processing status') || + message.startsWith('video processing timed out') || + message.startsWith('timed out waiting for video processing') || + message.startsWith('video file not found') || + message.startsWith('video file is empty'); + } } diff --git a/test/src/core/utils/error_messages_test.dart b/test/src/core/utils/error_messages_test.dart index 79e502d..1cb54cc 100644 --- a/test/src/core/utils/error_messages_test.dart +++ b/test/src/core/utils/error_messages_test.dart @@ -30,5 +30,30 @@ void main() { 'This file is too large to upload. Please trim or compress it and try again.', ); }); + + test('preserves detailed video processing failure reasons', () { + const error = VideoUploadException( + 'Video processing failed. Unsupported video codec: hev1.', + ); + + final message = ErrorMessages.getOperationErrorMessage('post', error); + + expect( + message, + 'Video processing failed. Unsupported video codec: hev1.', + ); + }); + + test( + 'preserves detailed upload failure reasons from generic exceptions', + () { + final message = ErrorMessages.getOperationErrorMessage( + 'post', + Exception('Failed to upload video. Unsupported container: mov.'), + ); + + expect(message, 'Failed to upload video. Unsupported container: mov.'); + }, + ); }); }