chore: 重构
This commit is contained in:
parent
56d8737bc9
commit
5abb5a6836
@ -23,6 +23,7 @@ export default function AskScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
useEffect(() => {
|
||||
checkAuthStatus(router);
|
||||
router.replace('/login');
|
||||
}, []);
|
||||
// 在组件内部添加 ref
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { defaultExifData, ExifData, ImagesuploaderProps, UploadUrlResponse } from '@/types/upload';
|
||||
import { addMaterial, confirmUpload, getUploadUrl } from '@/lib/background-uploader/api';
|
||||
import { ConfirmUpload, FileUploadItem, UploadResult, UploadTask } from '@/lib/background-uploader/types';
|
||||
import { uploadFileWithProgress } from '@/lib/background-uploader/uploader';
|
||||
import { compressImage } from '@/lib/image-process/imageCompress';
|
||||
import { defaultExifData, ExifData, ImagesuploaderProps } from '@/types/upload';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Location from 'expo-location';
|
||||
@ -8,46 +11,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
|
||||
import UploadPreview from './preview';
|
||||
|
||||
// 在文件顶部添加这些类型
|
||||
type UploadTask = {
|
||||
file: File;
|
||||
metadata: {
|
||||
isCompressed: string;
|
||||
type: string;
|
||||
isThumbnail?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
type FileUploadItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
||||
error: string | null;
|
||||
type: 'image' | 'video';
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
type ConfirmUpload = {
|
||||
file_id: string;
|
||||
upload_url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
file_path: string;
|
||||
};
|
||||
|
||||
type UploadResult = {
|
||||
originalUrl?: string;
|
||||
compressedUrl: string;
|
||||
file: File | null;
|
||||
exif: any;
|
||||
originalFile: ConfirmUpload;
|
||||
compressedFile: ConfirmUpload;
|
||||
thumbnail: string;
|
||||
thumbnailFile: File;
|
||||
};
|
||||
export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
children,
|
||||
style,
|
||||
@ -81,191 +44,6 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取上传URL
|
||||
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
|
||||
const body = {
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
file_size: file.size,
|
||||
metadata: {
|
||||
...metadata,
|
||||
originalName: file.name,
|
||||
fileType: 'image',
|
||||
isCompressed: metadata.isCompressed || 'false',
|
||||
},
|
||||
};
|
||||
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
};
|
||||
|
||||
// 向服务端confirm上传
|
||||
const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
file_id
|
||||
})
|
||||
});
|
||||
|
||||
// 新增素材
|
||||
const addMaterial = async (file: string, compressFile: string) => {
|
||||
await fetchApi('/material', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{
|
||||
"file_id": file,
|
||||
"preview_file_id": compressFile
|
||||
}])
|
||||
}).catch((error) => {
|
||||
// console.log(error);
|
||||
})
|
||||
}
|
||||
|
||||
// 上传文件到URL
|
||||
const uploadFileToUrl = async (file: File, uploadUrl: string, onProgress: (progress: number) => void): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
// 进度监听
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(progress);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('Network error during upload'));
|
||||
};
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 压缩并处理图片
|
||||
const processImage = async (uri: string, fileName: string, mimeType: string) => {
|
||||
try {
|
||||
// 压缩图片
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[
|
||||
{
|
||||
resize: {
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: compressQuality,
|
||||
format: ImageManipulator.SaveFormat.JPEG,
|
||||
base64: false,
|
||||
}
|
||||
);
|
||||
|
||||
// 获取压缩后的图片数据
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
// 创建文件对象
|
||||
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
|
||||
type: mimeType,
|
||||
});
|
||||
|
||||
return { file, uri: manipResult.uri };
|
||||
} catch (error) {
|
||||
// console.error('图片压缩失败:', error);
|
||||
throw new Error('图片处理失败');
|
||||
}
|
||||
};
|
||||
const uploadWithProgress = async (file: File, metadata: any): Promise<ConfirmUpload> => {
|
||||
let timeoutId: number
|
||||
console.log("uploadWithProgress", metadata);
|
||||
|
||||
try {
|
||||
console.log("Starting upload for file:", file.name, "size:", file.size, "type:", file.type);
|
||||
|
||||
// 检查文件大小
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error(`文件大小超过限制 (${(MAX_FILE_SIZE / 1024 / 1024).toFixed(1)}MB)`);
|
||||
}
|
||||
|
||||
const uploadUrlData = await getUploadUrl(file, { ...metadata, GPSVersionID: undefined });
|
||||
console.log("Got upload URL for:", file.name);
|
||||
|
||||
return new Promise<ConfirmUpload>((resolve, reject) => {
|
||||
try {
|
||||
// 设置超时
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error('上传超时,请检查网络连接'));
|
||||
}, 30000);
|
||||
|
||||
// 上传文件
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrlData.upload_url, true);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
console.log(`Upload progress for ${file.name}: ${progress}%`);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = async () => {
|
||||
clearTimeout(timeoutId!);
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const result = await confirmUpload(uploadUrlData.file_id);
|
||||
resolve({
|
||||
...result,
|
||||
file_id: uploadUrlData.file_id,
|
||||
upload_url: uploadUrlData.upload_url,
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`上传失败,状态码: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
clearTimeout(timeoutId!);
|
||||
reject(new Error('网络错误,请检查网络连接'));
|
||||
};
|
||||
|
||||
xhr.send(file);
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId!);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in uploadWithProgress:', {
|
||||
error,
|
||||
fileName: file?.name,
|
||||
fileSize: file?.size,
|
||||
fileType: file?.type
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单个资源
|
||||
const processSingleAsset = async (asset: ImagePicker.ImagePickerAsset): Promise<UploadResult | null> => {
|
||||
console.log("asset111111", asset);
|
||||
@ -329,15 +107,11 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
{ type: 'image/jpeg' }
|
||||
);
|
||||
} else {
|
||||
// 处理图片
|
||||
const [originalResponse, compressedFileResult] = await Promise.all([
|
||||
fetch(asset.uri),
|
||||
ImageManipulator.manipulateAsync(
|
||||
asset.uri,
|
||||
[{ resize: { width: 800 } }],
|
||||
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
|
||||
)
|
||||
]);
|
||||
// 处理图片,主图和缩略图都用 compressImage 方法
|
||||
// 主图压缩(按 maxWidth/maxHeight/compressQuality)
|
||||
const { file: compressedFile } = await compressImage(asset.uri, maxWidth);
|
||||
// 缩略图压缩(宽度800)
|
||||
const { file: thumbFile } = await compressImage(asset.uri, 800);
|
||||
|
||||
// 如果保留 EXIF 数据,则获取
|
||||
if (preserveExif && asset.exif) {
|
||||
@ -358,20 +132,10 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
const originalBlob = await originalResponse.blob();
|
||||
const compressedBlob = await compressedFileResult.file;
|
||||
|
||||
file = new File(
|
||||
[originalBlob],
|
||||
`original_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
|
||||
{ type: asset.mimeType || 'image/jpeg' }
|
||||
);
|
||||
|
||||
thumbnailFile = new File(
|
||||
[compressedBlob],
|
||||
`compressed_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
|
||||
{ type: 'image/jpeg' }
|
||||
);
|
||||
// 用压缩后主图作为上传主文件
|
||||
file = compressedFile as File;
|
||||
// 用缩略图文件作为预览
|
||||
thumbnailFile = thumbFile as File;
|
||||
}
|
||||
|
||||
// 准备上传任务
|
||||
@ -401,8 +165,11 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
const uploadResultsList = [];
|
||||
for (const task of uploadTasks) {
|
||||
try {
|
||||
const result = await uploadWithProgress(task.file, task.metadata);
|
||||
uploadResultsList.push(result);
|
||||
// 统一通过 lib 的 uploadFileWithProgress 实现上传
|
||||
const uploadUrlData = await getUploadUrl(task.file, { ...task.metadata, GPSVersionID: undefined });
|
||||
await uploadFileWithProgress(task.file, uploadUrlData.upload_url, updateProgress, 30000);
|
||||
const result = await confirmUpload(uploadUrlData.file_id);
|
||||
uploadResultsList.push({ ...result, file_id: uploadUrlData.file_id, upload_url: uploadUrlData.upload_url });
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
throw error;
|
||||
@ -435,7 +202,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
if (uploadResults.originalFile?.file_id) {
|
||||
await addMaterial(
|
||||
uploadResults.originalFile.file_id,
|
||||
uploadResults.thumbnail
|
||||
uploadResults.compressedFile?.file_id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,39 +1,39 @@
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as VideoThumbnail from 'expo-video-thumbnails';
|
||||
// import * as ImageManipulator from 'expo-image-manipulator';
|
||||
// import * as VideoThumbnail from 'expo-video-thumbnails';
|
||||
|
||||
export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => {
|
||||
try {
|
||||
// 获取视频的第一帧
|
||||
const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync(
|
||||
videoUri,
|
||||
{
|
||||
time: 1000, // 1秒的位置
|
||||
quality: 0.8,
|
||||
}
|
||||
);
|
||||
// export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => {
|
||||
// try {
|
||||
// // 获取视频的第一帧
|
||||
// const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync(
|
||||
// videoUri,
|
||||
// {
|
||||
// time: 1000, // 1秒的位置
|
||||
// quality: 0.8,
|
||||
// }
|
||||
// );
|
||||
|
||||
// 转换为 WebP 格式
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
thumbnailUri,
|
||||
[{ resize: { width: 800 } }], // 调整大小以提高性能
|
||||
{
|
||||
compress: 0.8,
|
||||
format: ImageManipulator.SaveFormat.WEBP
|
||||
}
|
||||
);
|
||||
// // 转换为 WebP 格式
|
||||
// const manipResult = await ImageManipulator.manipulateAsync(
|
||||
// thumbnailUri,
|
||||
// [{ resize: { width: 800 } }], // 调整大小以提高性能
|
||||
// {
|
||||
// compress: 0.8,
|
||||
// format: ImageManipulator.SaveFormat.WEBP
|
||||
// }
|
||||
// );
|
||||
|
||||
// 转换为 File 对象
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const file = new File(
|
||||
[blob],
|
||||
`thumb_${Date.now()}.webp`,
|
||||
{ type: 'image/webp' }
|
||||
);
|
||||
// // 转换为 File 对象
|
||||
// const response = await fetch(manipResult.uri);
|
||||
// const blob = await response.blob();
|
||||
// const file = new File(
|
||||
// [blob],
|
||||
// `thumb_${Date.now()}.webp`,
|
||||
// { type: 'image/webp' }
|
||||
// );
|
||||
|
||||
return { uri: manipResult.uri, file };
|
||||
} catch (error) {
|
||||
console.error('Error generating video thumbnail:', error);
|
||||
throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
};
|
||||
// return { uri: manipResult.uri, file };
|
||||
// } catch (error) {
|
||||
// console.error('Error generating video thumbnail:', error);
|
||||
// throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
|
||||
// }
|
||||
// };
|
||||
@ -1,138 +1,138 @@
|
||||
/**
|
||||
* 从视频文件中提取第一帧并返回为File对象
|
||||
* @param videoFile 视频文件
|
||||
* @returns 包含视频第一帧的File对象
|
||||
*/
|
||||
export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const videoUrl = URL.createObjectURL(videoFile);
|
||||
const video = document.createElement('video');
|
||||
video.src = videoUrl;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
// /**
|
||||
// * 从视频文件中提取第一帧并返回为File对象
|
||||
// * @param videoFile 视频文件
|
||||
// * @returns 包含视频第一帧的File对象
|
||||
// */
|
||||
// export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
|
||||
// return new Promise((resolve, reject) => {
|
||||
// const videoUrl = URL.createObjectURL(videoFile);
|
||||
// const video = document.createElement('video');
|
||||
// video.src = videoUrl;
|
||||
// video.crossOrigin = 'anonymous';
|
||||
// video.muted = true;
|
||||
// video.preload = 'metadata';
|
||||
|
||||
video.onloadeddata = () => {
|
||||
try {
|
||||
// 设置视频时间到第一帧
|
||||
video.currentTime = 0.1;
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
// video.onloadeddata = () => {
|
||||
// try {
|
||||
// // 设置视频时间到第一帧
|
||||
// video.currentTime = 0.1;
|
||||
// } catch (e) {
|
||||
// URL.revokeObjectURL(videoUrl);
|
||||
// reject(e);
|
||||
// }
|
||||
// };
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
// video.onseeked = () => {
|
||||
// try {
|
||||
// const canvas = document.createElement('canvas');
|
||||
// canvas.width = video.videoWidth;
|
||||
// canvas.height = video.videoHeight;
|
||||
// const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法获取canvas上下文');
|
||||
}
|
||||
// if (!ctx) {
|
||||
// throw new Error('无法获取canvas上下文');
|
||||
// }
|
||||
|
||||
// 绘制视频帧到canvas
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
// // 绘制视频帧到canvas
|
||||
// ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 将canvas转换为DataURL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||
// // 将canvas转换为DataURL
|
||||
// const dataUrl = canvas.toDataURL('image/jpeg');
|
||||
|
||||
// 将DataURL转换为Blob
|
||||
const byteString = atob(dataUrl.split(',')[1]);
|
||||
const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
// // 将DataURL转换为Blob
|
||||
// const byteString = atob(dataUrl.split(',')[1]);
|
||||
// const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
|
||||
// const ab = new ArrayBuffer(byteString.length);
|
||||
// const ia = new Uint8Array(ab);
|
||||
// for (let i = 0; i < byteString.length; i++) {
|
||||
// ia[i] = byteString.charCodeAt(i);
|
||||
// }
|
||||
// const blob = new Blob([ab], { type: mimeString });
|
||||
|
||||
// 创建File对象
|
||||
const frameFile = new File(
|
||||
[blob],
|
||||
`${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
|
||||
{ type: 'image/jpeg' }
|
||||
);
|
||||
// // 创建File对象
|
||||
// const frameFile = new File(
|
||||
// [blob],
|
||||
// `${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
|
||||
// { type: 'image/jpeg' }
|
||||
// );
|
||||
|
||||
// 清理URL对象
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
resolve(frameFile);
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
// // 清理URL对象
|
||||
// URL.revokeObjectURL(videoUrl);
|
||||
// resolve(frameFile);
|
||||
// } catch (e) {
|
||||
// URL.revokeObjectURL(videoUrl);
|
||||
// reject(e);
|
||||
// }
|
||||
// };
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(new Error('视频加载失败'));
|
||||
};
|
||||
});
|
||||
};
|
||||
// video.onerror = () => {
|
||||
// URL.revokeObjectURL(videoUrl);
|
||||
// reject(new Error('视频加载失败'));
|
||||
// };
|
||||
// });
|
||||
// };
|
||||
|
||||
// 获取视频时长
|
||||
export const getVideoDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
// // 获取视频时长
|
||||
// export const getVideoDuration = (file: File): Promise<number> => {
|
||||
// return new Promise((resolve) => {
|
||||
// const video = document.createElement('video');
|
||||
// video.preload = 'metadata';
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(video.duration);
|
||||
};
|
||||
// video.onloadedmetadata = () => {
|
||||
// URL.revokeObjectURL(video.src);
|
||||
// resolve(video.duration);
|
||||
// };
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(0); // Return 0 if we can't get the duration
|
||||
};
|
||||
// video.onerror = () => {
|
||||
// URL.revokeObjectURL(video.src);
|
||||
// resolve(0); // Return 0 if we can't get the duration
|
||||
// };
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
// video.src = URL.createObjectURL(file);
|
||||
// });
|
||||
// };
|
||||
|
||||
// 根据 mp4 的url来获取视频时长
|
||||
/**
|
||||
* 根据视频URL获取视频时长
|
||||
* @param videoUrl 视频的URL
|
||||
* @returns 返回一个Promise,解析为视频时长(秒)
|
||||
*/
|
||||
export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
// 创建临时的video元素
|
||||
const video = document.createElement('video');
|
||||
// // 根据 mp4 的url来获取视频时长
|
||||
// /**
|
||||
// * 根据视频URL获取视频时长
|
||||
// * @param videoUrl 视频的URL
|
||||
// * @returns 返回一个Promise,解析为视频时长(秒)
|
||||
// */
|
||||
// export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
|
||||
// return await new Promise((resolve, reject) => {
|
||||
// // 创建临时的video元素
|
||||
// const video = document.createElement('video');
|
||||
|
||||
// 设置为只加载元数据,不加载整个视频
|
||||
video.preload = 'metadata';
|
||||
// // 设置为只加载元数据,不加载整个视频
|
||||
// video.preload = 'metadata';
|
||||
|
||||
// 处理加载成功
|
||||
video.onloadedmetadata = () => {
|
||||
// 释放URL对象
|
||||
URL.revokeObjectURL(video.src);
|
||||
// 返回视频时长(秒)
|
||||
resolve(video.duration);
|
||||
};
|
||||
// // 处理加载成功
|
||||
// video.onloadedmetadata = () => {
|
||||
// // 释放URL对象
|
||||
// URL.revokeObjectURL(video.src);
|
||||
// // 返回视频时长(秒)
|
||||
// resolve(video.duration);
|
||||
// };
|
||||
|
||||
// 处理加载错误
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
reject(new Error('无法加载视频'));
|
||||
};
|
||||
// // 处理加载错误
|
||||
// video.onerror = () => {
|
||||
// URL.revokeObjectURL(video.src);
|
||||
// reject(new Error('无法加载视频'));
|
||||
// };
|
||||
|
||||
// 处理网络错误
|
||||
video.onabort = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
reject(new Error('视频加载被中止'));
|
||||
};
|
||||
// // 处理网络错误
|
||||
// video.onabort = () => {
|
||||
// URL.revokeObjectURL(video.src);
|
||||
// reject(new Error('视频加载被中止'));
|
||||
// };
|
||||
|
||||
// 设置视频源
|
||||
video.src = videoUrl;
|
||||
// // 设置视频源
|
||||
// video.src = videoUrl;
|
||||
|
||||
// 添加跨域属性(如果需要)
|
||||
video.setAttribute('crossOrigin', 'anonymous');
|
||||
// // 添加跨域属性(如果需要)
|
||||
// video.setAttribute('crossOrigin', 'anonymous');
|
||||
|
||||
// 开始加载元数据
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
// // 开始加载元数据
|
||||
// video.load();
|
||||
// });
|
||||
// };
|
||||
|
||||
11
jest.config.ts
Normal file
11
jest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
const { createDefaultPreset } = require("ts-jest");
|
||||
|
||||
const tsJestTransformCfg = createDefaultPreset().transform;
|
||||
|
||||
/** @type {import("jest").Config} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
...tsJestTransformCfg,
|
||||
},
|
||||
};
|
||||
@ -12,7 +12,7 @@ export async function identityCheck(token: string) {
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.code == 0;
|
||||
return data.code != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,10 +28,7 @@ export async function checkAuthStatus(router: ReturnType<typeof useRouter>, onAu
|
||||
}
|
||||
|
||||
const loggedIn = !!token && await identityCheck(token);
|
||||
console.log('token', token);
|
||||
console.log('loggedIn', loggedIn);
|
||||
if (!loggedIn) {
|
||||
console.log('未登录');
|
||||
router.replace('/login');
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { ConfirmUpload, UploadUrlResponse } from './types';
|
||||
|
||||
// 获取上传URL
|
||||
export const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<{ upload_url: string; file_id: string }> => {
|
||||
|
||||
export const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
|
||||
const body = {
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
@ -11,19 +11,19 @@ export const getUploadUrl = async (file: File, metadata: Record<string, any> = {
|
||||
...metadata,
|
||||
originalName: file.name,
|
||||
fileType: file.type.startsWith('video/') ? 'video' : 'image',
|
||||
isCompressed: 'true',
|
||||
isCompressed: metadata.isCompressed || 'false',
|
||||
},
|
||||
};
|
||||
|
||||
return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", {
|
||||
return await fetchApi<UploadUrlResponse>('/file/generate-upload-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
};
|
||||
|
||||
// 确认上传
|
||||
export const confirmUpload = async (file_id: string) => {
|
||||
return await fetchApi('/file/confirm-upload', {
|
||||
// 确认上传
|
||||
export const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => {
|
||||
return await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_id })
|
||||
});
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { confirmUpload, getUploadUrl } from './api';
|
||||
import { ExtendedAsset } from './types';
|
||||
import { getUploadUrl, confirmUpload } from './api';
|
||||
import { uploadFile } from './uploader';
|
||||
|
||||
// 将 HEIC 图片转化
|
||||
@ -39,16 +38,7 @@ export const convertHeicToJpeg = async (uri: string): Promise<File> => {
|
||||
throw new Error('Failed to read file as base64');
|
||||
}
|
||||
|
||||
// 4. 创建 Blob
|
||||
const response = await fetch(`data:image/jpeg;base64,${base64}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error('Failed to create blob from base64');
|
||||
}
|
||||
|
||||
// 5. 创建文件名
|
||||
const originalName = uri.split('/').pop() || 'converted';
|
||||
@ -78,35 +68,7 @@ export const convertHeicToJpeg = async (uri: string): Promise<File> => {
|
||||
};
|
||||
|
||||
// 压缩图片
|
||||
export const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => {
|
||||
try {
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[
|
||||
{
|
||||
resize: {
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: 0.7,
|
||||
format: ImageManipulator.SaveFormat.JPEG,
|
||||
base64: false,
|
||||
}
|
||||
);
|
||||
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`;
|
||||
const file = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
return { uri: manipResult.uri, file };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
import { compressImage } from '../image-process/imageCompress';
|
||||
|
||||
// 提取视频的首帧进行压缩并上传
|
||||
export const uploadVideoThumbnail = async (asset: ExtendedAsset) => {
|
||||
|
||||
@ -4,7 +4,8 @@ import { transformData } from '@/components/utils/objectFlat';
|
||||
import { ExtendedAsset } from './types';
|
||||
import { getMediaByDateRange } from './media';
|
||||
import { checkMediaLibraryPermission, getFileExtension, getMimeType } from './utils';
|
||||
import { convertHeicToJpeg, compressImage, uploadVideoThumbnail } from './fileProcessor';
|
||||
import { convertHeicToJpeg, uploadVideoThumbnail } from './fileProcessor';
|
||||
import { compressImage } from '../image-process/imageCompress';
|
||||
import { getUploadUrl, confirmUpload, addMaterial } from './api';
|
||||
import { uploadFile } from './uploader';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
@ -3,3 +3,53 @@ import * as MediaLibrary from 'expo-media-library';
|
||||
export type ExtendedAsset = MediaLibrary.Asset & {
|
||||
exif?: Record<string, any>;
|
||||
};
|
||||
|
||||
// 上传任务类型
|
||||
export type UploadTask = {
|
||||
file: File;
|
||||
metadata: {
|
||||
isCompressed: string;
|
||||
type: string;
|
||||
isThumbnail?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
// 上传队列项
|
||||
export type FileUploadItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
||||
error: string | null;
|
||||
type: 'image' | 'video';
|
||||
thumbnail: string | null;
|
||||
};
|
||||
|
||||
// 确认上传返回
|
||||
export type ConfirmUpload = {
|
||||
file_id: string;
|
||||
upload_url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
file_path: string;
|
||||
};
|
||||
|
||||
// 上传结果
|
||||
export type UploadResult = {
|
||||
originalUrl?: string;
|
||||
compressedUrl: string;
|
||||
file: File | null;
|
||||
exif: any;
|
||||
originalFile: ConfirmUpload;
|
||||
compressedFile: ConfirmUpload;
|
||||
thumbnail: string;
|
||||
thumbnailFile: File;
|
||||
};
|
||||
|
||||
// 上传URL响应类型
|
||||
export type UploadUrlResponse = {
|
||||
upload_url: string;
|
||||
file_id: string;
|
||||
};
|
||||
@ -1,19 +1,49 @@
|
||||
// 上传文件到URL
|
||||
// 上传文件到URL(基础版,无进度回调)
|
||||
export const uploadFile = async (file: File, uploadUrl: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('Network error during upload'));
|
||||
};
|
||||
xhr.send(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 支持进度回调和超时的上传实现
|
||||
export const uploadFileWithProgress = async (
|
||||
file: File,
|
||||
uploadUrl: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
timeout: number = 30000
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
// 进度监听
|
||||
if (onProgress) {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(progress);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
@ -22,9 +52,16 @@ export const uploadFile = async (file: File, uploadUrl: string): Promise<void> =
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error('Network error during upload'));
|
||||
};
|
||||
|
||||
// 超时处理
|
||||
timeoutId = setTimeout(() => {
|
||||
xhr.abort();
|
||||
reject(new Error('上传超时,请检查网络连接'));
|
||||
}, timeout);
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
};
|
||||
47
lib/image-process/heicConvert.ts
Normal file
47
lib/image-process/heicConvert.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
// 将 HEIC/HEIF 图片转为 JPEG
|
||||
export const convertHeicToJpeg = async (uri: string): Promise<File> => {
|
||||
try {
|
||||
// 1. 将文件复制到缓存目录
|
||||
const cacheDir = FileSystem.cacheDirectory;
|
||||
if (!cacheDir) {
|
||||
throw new Error('Cache directory not available');
|
||||
}
|
||||
const tempUri = `${cacheDir}${Date.now()}.heic`;
|
||||
await FileSystem.copyAsync({ from: uri, to: tempUri });
|
||||
// 2. 检查文件是否存在
|
||||
const fileInfo = await FileSystem.getInfoAsync(tempUri);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Temporary file was not created');
|
||||
}
|
||||
// 3. 读取文件为 base64
|
||||
const base64 = await FileSystem.readAsStringAsync(tempUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
if (!base64) {
|
||||
throw new Error('Failed to read file as base64');
|
||||
}
|
||||
// 4. 创建 Blob
|
||||
const response = await fetch(`data:image/jpeg;base64,${base64}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed with status ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error('Failed to create blob from base64');
|
||||
}
|
||||
// 5. 创建文件名
|
||||
const originalName = uri.split('/').pop() || 'converted';
|
||||
const filename = originalName.replace(/\.(heic|heif)$/i, '.jpg');
|
||||
// 清理临时文件
|
||||
try {
|
||||
await FileSystem.deleteAsync(tempUri, { idempotent: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary file:', cleanupError);
|
||||
}
|
||||
return new File([blob], filename, { type: 'image/jpeg' });
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
|
||||
}
|
||||
};
|
||||
56
lib/image-process/imageCompress.ts
Normal file
56
lib/image-process/imageCompress.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { Image } from 'react-native';
|
||||
|
||||
// 压缩图片,自动等比缩放,最大边不超过 800
|
||||
export const compressImage = async (uri: string, maxSize = 800): Promise<{ uri: string; file: File }> => {
|
||||
// 获取原图尺寸
|
||||
const getImageSize = (uri: string): Promise<{ width: number; height: number }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => resolve({ width, height }),
|
||||
reject
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const { width, height } = await getImageSize(uri);
|
||||
let targetWidth = width;
|
||||
let targetHeight = height;
|
||||
if (width > maxSize || height > maxSize) {
|
||||
if (width > height) {
|
||||
targetWidth = maxSize;
|
||||
targetHeight = Math.round((height / width) * maxSize);
|
||||
} else {
|
||||
targetHeight = maxSize;
|
||||
targetWidth = Math.round((width / height) * maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[
|
||||
{
|
||||
resize: {
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: 0.7,
|
||||
format: ImageManipulator.SaveFormat.WEBP,
|
||||
base64: false,
|
||||
}
|
||||
);
|
||||
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = uri.split('/').pop() || `image_${Date.now()}.webp`;
|
||||
const file = new File([blob], filename, { type: 'image/webp' });
|
||||
|
||||
return { uri: manipResult.uri, file };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
31
lib/video-process/videoThumbnail.ts
Normal file
31
lib/video-process/videoThumbnail.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { getUploadUrl, confirmUpload } from '../background-uploader/api';
|
||||
import { uploadFile } from '../background-uploader/uploader';
|
||||
import { compressImage } from '../image-process/imageCompress';
|
||||
import { ExtendedAsset } from '../background-uploader/types';
|
||||
|
||||
// 提取视频的首帧进行压缩并上传
|
||||
export const uploadVideoThumbnail = async (asset: ExtendedAsset) => {
|
||||
try {
|
||||
const manipResult = await compressImage(asset.uri);
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = asset.filename ?
|
||||
`compressed_${asset.filename}` :
|
||||
`image_${Date.now()}_compressed.jpg`;
|
||||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: 'image',
|
||||
isCompressed: true
|
||||
});
|
||||
|
||||
await uploadFile(compressedFile, upload_url);
|
||||
await confirmUpload(file_id);
|
||||
|
||||
return { success: true, file_id };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
5433
package-lock.json
generated
5433
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,8 @@
|
||||
"web": "expo start --web --port 5173",
|
||||
"lint": "expo lint",
|
||||
"prebuild": "npm run generate:translations",
|
||||
"generate:translations": "tsx i18n/generate-imports.ts"
|
||||
"generate:translations": "tsx i18n/generate-imports.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
@ -74,13 +75,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "~19.0.10",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~9.2.0",
|
||||
"jest": "^30.0.4",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-jest": "^29.4.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.8.3"
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"],
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user