Merge remote-tracking branch 'origin/feature/v1.2灵机空间-内容上传审核_rxd' into test
# Conflicts: # pnpm-lock.yaml # src/components/text-over-tips/index.vue # src/layouts/Basic.vue # src/layouts/Page.vue # src/main.ts # src/router/constants.ts # src/router/index.ts # src/router/typeings.d.ts # src/utils/tools.ts
This commit is contained in:
@ -111,6 +111,235 @@ export function genRandomId() {
|
||||
return `id_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 修改函数以同时获取视频时长和首帧
|
||||
export function getVideoInfo(file: File): Promise<{ duration: number; firstFrame: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.crossOrigin = 'anonymous'; // 避免跨域问题
|
||||
video.muted = true; // 静音,避免意外播放声音
|
||||
video.style.position = 'fixed'; // 确保视频元素在DOM中
|
||||
video.style.top = '-1000px'; // 但不可见
|
||||
document.body.appendChild(video); // 添加到DOM
|
||||
|
||||
let hasResolved = false;
|
||||
|
||||
// 先获取元数据(时长)
|
||||
video.onloadedmetadata = function () {
|
||||
// 视频时长
|
||||
const duration = video.duration;
|
||||
|
||||
// 尝试将视频定位到非常小的时间点,确保有帧可捕获
|
||||
if (duration > 0) {
|
||||
video.currentTime = Math.min(0.1, duration / 2);
|
||||
}
|
||||
};
|
||||
|
||||
// 当视频定位完成后尝试捕获首帧
|
||||
video.onseeked = function () {
|
||||
if (hasResolved) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 清理
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
document.body.removeChild(video);
|
||||
|
||||
// 返回结果
|
||||
hasResolved = true;
|
||||
resolve({
|
||||
duration: video.duration,
|
||||
firstFrame: canvas.toDataURL('image/jpeg', 0.9), // 提高质量
|
||||
});
|
||||
};
|
||||
|
||||
// 作为备选方案,监听loadeddata事件
|
||||
video.onloadeddata = function () {
|
||||
if (hasResolved) return;
|
||||
|
||||
// 尝试捕获帧
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 检查是否捕获到有效帧(非全黑)
|
||||
const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height);
|
||||
if (imageData) {
|
||||
let isAllBlack = true;
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
if (imageData.data[i] > 10 || imageData.data[i + 1] > 10 || imageData.data[i + 2] > 10) {
|
||||
isAllBlack = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAllBlack) {
|
||||
// 清理
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
document.body.removeChild(video);
|
||||
|
||||
// 返回结果
|
||||
hasResolved = true;
|
||||
resolve({
|
||||
duration: video.duration,
|
||||
firstFrame: canvas.toDataURL('image/jpeg', 0.9),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是全黑帧,尝试定位到0.1秒
|
||||
if (video.duration > 0) {
|
||||
video.currentTime = 0.1;
|
||||
}
|
||||
};
|
||||
|
||||
// 设置视频源以触发加载
|
||||
video.src = URL.createObjectURL(file);
|
||||
|
||||
// 设置超时,防止长时间无响应
|
||||
setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
document.body.removeChild(video);
|
||||
resolve({
|
||||
duration: 0,
|
||||
firstFrame: '',
|
||||
});
|
||||
}
|
||||
}, 5000); // 5秒超时
|
||||
});
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const remainingSecondsAfterHours = seconds % 3600;
|
||||
const minutes = Math.floor(remainingSecondsAfterHours / 60);
|
||||
const remainingSeconds = Math.floor(remainingSecondsAfterHours % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
if (remainingSecondsAfterHours === 0) {
|
||||
return `${hours}小时`;
|
||||
}
|
||||
if (remainingSeconds === 0) {
|
||||
return `${hours}小时${minutes}分`;
|
||||
}
|
||||
return `${hours}小时${minutes}分${remainingSeconds}秒`;
|
||||
} else if (minutes > 0) {
|
||||
if (remainingSeconds === 0) {
|
||||
return `${minutes}分`;
|
||||
}
|
||||
return `${minutes}分${remainingSeconds}秒`;
|
||||
} else {
|
||||
return `${remainingSeconds}秒`;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatUploadSpeed = (bytesPerSecond: number): string => {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${bytesPerSecond.toFixed(2)} B/s`;
|
||||
} else if (bytesPerSecond < 1024 * 1024) {
|
||||
return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`;
|
||||
} else {
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s`;
|
||||
}
|
||||
};
|
||||
|
||||
export function convertVideoUrlToCoverUrl(videoUrl: string): string {
|
||||
if (!videoUrl || typeof videoUrl !== 'string') {
|
||||
console.error('Invalid video URL');
|
||||
return '';
|
||||
}
|
||||
|
||||
const urlWithCovers = videoUrl.replace('/videos/', '/covers/');
|
||||
|
||||
const lastDotIndex = urlWithCovers.lastIndexOf('.');
|
||||
if (lastDotIndex !== -1) {
|
||||
return urlWithCovers.substring(0, lastDotIndex) + '.jpg';
|
||||
}
|
||||
|
||||
return urlWithCovers + '.jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成包含协议、域名和参数的完整URL
|
||||
*/
|
||||
export const generateFullUrl = (pathTemplate: string, params: Record<string, string | number> = {}): string => {
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
const baseUrl = `${protocol}//${hostname}${port}`;
|
||||
|
||||
let path = pathTemplate;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
path = path.replace(`:${key}`, String(value));
|
||||
});
|
||||
|
||||
return `${baseUrl}${path}`;
|
||||
};
|
||||
|
||||
/** 图片朝向类型 */
|
||||
export type ImageOrientation = 'landscape' | 'portrait' | 'square';
|
||||
|
||||
/**
|
||||
* 根据宽高判断图片是横图/竖图/正方形
|
||||
*/
|
||||
export const getImageOrientationBySize = (width: number, height: number): ImageOrientation => {
|
||||
if (!width || !height) return 'square';
|
||||
if (width > height) return 'landscape';
|
||||
if (height > width) return 'portrait';
|
||||
return 'square';
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载图片自然尺寸(跨域下需服务端允许匿名访问)
|
||||
*/
|
||||
export const getImageNaturalSize = (url: string): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ width: 0, height: 0 });
|
||||
};
|
||||
// 若是跨域资源并允许匿名访问,可尝试设置
|
||||
try {
|
||||
img.crossOrigin = 'anonymous';
|
||||
} catch (_) {}
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 基于图片 URL 判断图片横竖
|
||||
*/
|
||||
export const getImageOrientationByUrl = async (url: string): Promise<ImageOrientation> => {
|
||||
const { width, height } = await getImageNaturalSize(url);
|
||||
return getImageOrientationBySize(width, height);
|
||||
};
|
||||
|
||||
export function getImageMainColor(imageUrl: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@ -168,7 +397,7 @@ export function getImageMainColor(imageUrl: string): Promise<string> {
|
||||
const avgColor = {
|
||||
r: Math.round(maxGroup.sumR / maxGroup.count),
|
||||
g: Math.round(maxGroup.sumG / maxGroup.count),
|
||||
b: Math.round(maxGroup.sumB / maxGroup.count)
|
||||
b: Math.round(maxGroup.sumB / maxGroup.count),
|
||||
};
|
||||
|
||||
resolve(`rgb(${avgColor.r},${avgColor.g},${avgColor.b})`);
|
||||
@ -198,28 +427,38 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
|
||||
if (a < 128) continue;
|
||||
|
||||
colors.push({
|
||||
r, g, b,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
count: 1,
|
||||
sumR: r, sumG: g, sumB: b
|
||||
sumR: r,
|
||||
sumG: g,
|
||||
sumB: b,
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有颜色数据,返回默认白色
|
||||
if (colors.length === 0) {
|
||||
return [{
|
||||
count: 1,
|
||||
sumR: 255, sumG: 255, sumB: 255
|
||||
}];
|
||||
return [
|
||||
{
|
||||
count: 1,
|
||||
sumR: 255,
|
||||
sumG: 255,
|
||||
sumB: 255,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 开始中位数切分
|
||||
let colorGroups = [{
|
||||
colors,
|
||||
count: colors.length,
|
||||
sumR: colors.reduce((sum, c) => sum + c.r, 0),
|
||||
sumG: colors.reduce((sum, c) => sum + c.g, 0),
|
||||
sumB: colors.reduce((sum, c) => sum + c.b, 0)
|
||||
}];
|
||||
let colorGroups = [
|
||||
{
|
||||
colors,
|
||||
count: colors.length,
|
||||
sumR: colors.reduce((sum, c) => sum + c.r, 0),
|
||||
sumG: colors.reduce((sum, c) => sum + c.g, 0),
|
||||
sumB: colors.reduce((sum, c) => sum + c.b, 0),
|
||||
},
|
||||
];
|
||||
|
||||
for (let i = 0; i < levels; i++) {
|
||||
const newGroups = [];
|
||||
@ -231,12 +470,12 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
|
||||
}
|
||||
|
||||
// 找出颜色范围最大的通道
|
||||
const rMin = Math.min(...group.colors.map(c => c.r));
|
||||
const rMax = Math.max(...group.colors.map(c => c.r));
|
||||
const gMin = Math.min(...group.colors.map(c => c.g));
|
||||
const gMax = Math.max(...group.colors.map(c => c.g));
|
||||
const bMin = Math.min(...group.colors.map(c => c.b));
|
||||
const bMax = Math.max(...group.colors.map(c => c.b));
|
||||
const rMin = Math.min(...group.colors.map((c) => c.r));
|
||||
const rMax = Math.max(...group.colors.map((c) => c.r));
|
||||
const gMin = Math.min(...group.colors.map((c) => c.g));
|
||||
const gMax = Math.max(...group.colors.map((c) => c.g));
|
||||
const bMin = Math.min(...group.colors.map((c) => c.b));
|
||||
const bMax = Math.max(...group.colors.map((c) => c.b));
|
||||
|
||||
const rRange = rMax - rMin;
|
||||
const gRange = gMax - gMin;
|
||||
@ -250,7 +489,9 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
|
||||
}
|
||||
|
||||
// 按最大范围通道排序
|
||||
group.colors.sort((a, b) => a[sortChannel] - b[sortChannel]);
|
||||
type ColorChannel = 'r' | 'g' | 'b';
|
||||
const safeSortChannel = sortChannel as ColorChannel;
|
||||
group.colors.sort((a, b) => a[safeSortChannel] - b[safeSortChannel]);
|
||||
|
||||
// 切分中位数
|
||||
const mid = Math.floor(group.colors.length / 2);
|
||||
@ -263,7 +504,7 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
|
||||
count: group1.length,
|
||||
sumR: group1.reduce((sum, c) => sum + c.r, 0),
|
||||
sumG: group1.reduce((sum, c) => sum + c.g, 0),
|
||||
sumB: group1.reduce((sum, c) => sum + c.b, 0)
|
||||
sumB: group1.reduce((sum, c) => sum + c.b, 0),
|
||||
});
|
||||
|
||||
newGroups.push({
|
||||
@ -271,7 +512,7 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
|
||||
count: group2.length,
|
||||
sumR: group2.reduce((sum, c) => sum + c.r, 0),
|
||||
sumG: group2.reduce((sum, c) => sum + c.g, 0),
|
||||
sumB: group2.reduce((sum, c) => sum + c.b, 0)
|
||||
sumB: group2.reduce((sum, c) => sum + c.b, 0),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user