Initial TrueGrowth source import
This commit is contained in:
147
packages/utils/src/media/image.test.ts
Normal file
147
packages/utils/src/media/image.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Image Utilities Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getCompressionStrategy,
|
||||
isBorderColor,
|
||||
isBackgroundPixel,
|
||||
isWhiteBorderPixel,
|
||||
parsePixelSize,
|
||||
} from './image';
|
||||
|
||||
describe('getCompressionStrategy', () => {
|
||||
it('should return no compression for files < 10MB', () => {
|
||||
const strategy = getCompressionStrategy(5);
|
||||
expect(strategy.shouldCompress).toBe(false);
|
||||
});
|
||||
|
||||
it('should return medium strategy for 10-15MB files', () => {
|
||||
const strategy = getCompressionStrategy(12);
|
||||
expect(strategy.shouldCompress).toBe(true);
|
||||
expect(strategy.targetSizeMB).toBe(5);
|
||||
expect(strategy.initialQuality).toBe(0.8);
|
||||
});
|
||||
|
||||
it('should return large strategy for 15-20MB files', () => {
|
||||
const strategy = getCompressionStrategy(17);
|
||||
expect(strategy.shouldCompress).toBe(true);
|
||||
expect(strategy.targetSizeMB).toBe(3);
|
||||
expect(strategy.initialQuality).toBe(0.7);
|
||||
});
|
||||
|
||||
it('should return veryLarge strategy for 20-25MB files', () => {
|
||||
const strategy = getCompressionStrategy(22);
|
||||
expect(strategy.shouldCompress).toBe(true);
|
||||
expect(strategy.targetSizeMB).toBe(2);
|
||||
expect(strategy.initialQuality).toBe(0.6);
|
||||
});
|
||||
|
||||
it('should return no compression for files > 25MB (caller handles)', () => {
|
||||
const strategy = getCompressionStrategy(30);
|
||||
expect(strategy.shouldCompress).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(getCompressionStrategy(10).shouldCompress).toBe(true);
|
||||
expect(getCompressionStrategy(15).shouldCompress).toBe(true);
|
||||
expect(getCompressionStrategy(20).shouldCompress).toBe(true);
|
||||
expect(getCompressionStrategy(25).shouldCompress).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBorderColor', () => {
|
||||
it('should detect white pixels', () => {
|
||||
expect(isBorderColor(255, 255, 255)).toBe(true);
|
||||
expect(isBorderColor(240, 240, 240)).toBe(true);
|
||||
expect(isBorderColor(230, 230, 230)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect gray pixels', () => {
|
||||
expect(isBorderColor(200, 200, 200)).toBe(true);
|
||||
expect(isBorderColor(180, 180, 180)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect black pixels', () => {
|
||||
expect(isBorderColor(0, 0, 0)).toBe(true);
|
||||
expect(isBorderColor(5, 5, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect transparent pixels', () => {
|
||||
expect(isBorderColor(100, 100, 100, { alpha: 50 })).toBe(true);
|
||||
expect(isBorderColor(100, 100, 100, { alpha: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject colored pixels', () => {
|
||||
expect(isBorderColor(255, 0, 0)).toBe(false); // Red
|
||||
expect(isBorderColor(0, 255, 0)).toBe(false); // Green
|
||||
expect(isBorderColor(0, 0, 255)).toBe(false); // Blue
|
||||
expect(isBorderColor(100, 150, 200)).toBe(false); // Mixed color
|
||||
});
|
||||
|
||||
it('should respect custom thresholds', () => {
|
||||
expect(isBorderColor(200, 200, 200, { whiteThreshold: 250 })).toBe(true); // Still gray
|
||||
expect(isBorderColor(220, 220, 220, { whiteThreshold: 210 })).toBe(true); // Above white threshold
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBackgroundPixel', () => {
|
||||
it('should detect light gray pixels', () => {
|
||||
expect(isBackgroundPixel(200, 200, 200)).toBe(true);
|
||||
expect(isBackgroundPixel(220, 220, 220)).toBe(true);
|
||||
expect(isBackgroundPixel(255, 255, 255)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject dark pixels', () => {
|
||||
expect(isBackgroundPixel(100, 100, 100)).toBe(false);
|
||||
expect(isBackgroundPixel(50, 50, 50)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject colored pixels', () => {
|
||||
expect(isBackgroundPixel(200, 100, 100)).toBe(false);
|
||||
expect(isBackgroundPixel(200, 200, 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect custom thresholds', () => {
|
||||
expect(isBackgroundPixel(150, 150, 150, { minGray: 140 })).toBe(true);
|
||||
expect(isBackgroundPixel(150, 150, 150, { minGray: 160 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWhiteBorderPixel', () => {
|
||||
it('should detect white pixels', () => {
|
||||
expect(isWhiteBorderPixel(255, 255, 255)).toBe(true);
|
||||
expect(isWhiteBorderPixel(250, 250, 250)).toBe(true);
|
||||
expect(isWhiteBorderPixel(245, 245, 245)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-white pixels', () => {
|
||||
expect(isWhiteBorderPixel(240, 240, 240)).toBe(false);
|
||||
expect(isWhiteBorderPixel(200, 200, 200)).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect custom threshold', () => {
|
||||
expect(isWhiteBorderPixel(240, 240, 240, 235)).toBe(true);
|
||||
expect(isWhiteBorderPixel(230, 230, 230, 225)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePixelSize', () => {
|
||||
it('parses pixel size tokens', () => {
|
||||
expect(parsePixelSize('1280x720')).toEqual({ width: 1280, height: 720 });
|
||||
expect(parsePixelSize(' 720X1280 ')).toEqual({
|
||||
width: 720,
|
||||
height: 1280,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for ratio strings and invalid values', () => {
|
||||
expect(parsePixelSize('16:9')).toBeNull();
|
||||
expect(parsePixelSize('auto')).toBeNull();
|
||||
expect(parsePixelSize('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Note: loadImage, createCanvasFromImage, compressImageBlob, trimBorders, etc.
|
||||
// require DOM environment and are better tested in E2E tests
|
||||
1085
packages/utils/src/media/image.ts
Normal file
1085
packages/utils/src/media/image.ts
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/utils/src/media/index.ts
Normal file
44
packages/utils/src/media/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Media Utilities
|
||||
*
|
||||
* 媒体处理工具模块
|
||||
* - 视频:缩略图生成、帧提取、时间格式化
|
||||
* - 图片:压缩、加载、去白边/透明边
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Video utilities
|
||||
export {
|
||||
calculateThumbnailSize,
|
||||
generateVideoThumbnailFromBlob,
|
||||
extractVideoFrame,
|
||||
extractFirstFrame,
|
||||
extractLastFrame,
|
||||
formatVideoTimestamp,
|
||||
} from './video';
|
||||
|
||||
// Image utilities
|
||||
export {
|
||||
// 图片加载
|
||||
loadImage,
|
||||
createCanvasFromImage,
|
||||
parsePixelSize,
|
||||
normalizeImageBlobToSize,
|
||||
// 压缩
|
||||
getCompressionStrategy,
|
||||
compressImageBlob,
|
||||
compressImageBlobWithStats,
|
||||
// 边框检测
|
||||
isBorderColor,
|
||||
isBackgroundPixel,
|
||||
isWhiteBorderPixel,
|
||||
// 边框裁剪
|
||||
trimBorders,
|
||||
trimCanvasBorders,
|
||||
trimCanvasWhiteAndTransparentBorder,
|
||||
trimCanvasWhiteAndTransparentBorderWithInfo,
|
||||
trimImageWhiteAndTransparentBorder,
|
||||
removeWhiteBorder,
|
||||
} from './image';
|
||||
145
packages/utils/src/media/types.ts
Normal file
145
packages/utils/src/media/types.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Media Utilities - Shared Types
|
||||
*
|
||||
* 媒体处理工具的共享类型定义
|
||||
*/
|
||||
|
||||
// ==================== Video Types ====================
|
||||
|
||||
/**
|
||||
* 提取的视频帧信息
|
||||
*/
|
||||
export interface ExtractedFrame {
|
||||
/** 帧的 Data URL (PNG 格式) */
|
||||
dataUrl: string;
|
||||
/** 帧在视频中的时间戳(秒) */
|
||||
timestamp: number;
|
||||
/** 视频宽度 */
|
||||
width: number;
|
||||
/** 视频高度 */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频帧提取选项
|
||||
*/
|
||||
export interface ExtractFrameOptions {
|
||||
/** 目标时间戳(秒)。'last' 提取最后一帧,'first' 提取第一帧。默认 'last' */
|
||||
timestamp?: number | 'last' | 'first';
|
||||
/** 输出图片格式。默认 'image/png' */
|
||||
format?: 'image/png' | 'image/jpeg';
|
||||
/** JPEG 格式的质量 (0-1)。默认 0.92 */
|
||||
quality?: number;
|
||||
/** 超时时间(毫秒)。默认 30000 */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩略图尺寸
|
||||
*/
|
||||
export interface ThumbnailSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// ==================== Image Types ====================
|
||||
|
||||
/**
|
||||
* 图片压缩策略
|
||||
*/
|
||||
export interface CompressionStrategy {
|
||||
/** 是否需要压缩 */
|
||||
shouldCompress: boolean;
|
||||
/** 目标大小 (MB) */
|
||||
targetSizeMB: number;
|
||||
/** 初始质量 (0-1) */
|
||||
initialQuality: number;
|
||||
/** 最小质量 (0-1) */
|
||||
minQuality: number;
|
||||
/** 最大质量 (0-1) */
|
||||
maxQuality: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片压缩结果
|
||||
*/
|
||||
export interface CompressionResult {
|
||||
/** 压缩后的 Blob */
|
||||
compressed: Blob;
|
||||
/** 原始大小(字节) */
|
||||
originalSize: number;
|
||||
/** 压缩后大小(字节) */
|
||||
compressedSize: number;
|
||||
/** 最终使用的质量 */
|
||||
quality: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 边框裁剪结果
|
||||
*/
|
||||
export interface BorderTrimResult {
|
||||
/** 顶部裁剪位置 */
|
||||
top: number;
|
||||
/** 右侧裁剪位置 */
|
||||
right: number;
|
||||
/** 底部裁剪位置 */
|
||||
bottom: number;
|
||||
/** 左侧裁剪位置 */
|
||||
left: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas 裁剪结果(带详细信息)
|
||||
*/
|
||||
export interface CanvasTrimResult {
|
||||
/** 裁剪后的 Canvas */
|
||||
canvas: HTMLCanvasElement;
|
||||
/** 左侧裁剪偏移 */
|
||||
left: number;
|
||||
/** 顶部裁剪偏移 */
|
||||
top: number;
|
||||
/** 裁剪后宽度 */
|
||||
trimmedWidth: number;
|
||||
/** 裁剪后高度 */
|
||||
trimmedHeight: number;
|
||||
/** 是否进行了裁剪 */
|
||||
wasTrimmed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 边框颜色检测选项
|
||||
*/
|
||||
export interface BorderColorOptions {
|
||||
/** 白色阈值,RGB 都大于此值视为白色(默认 230) */
|
||||
whiteThreshold?: number;
|
||||
/** 灰色最小值(默认 150) */
|
||||
grayMinValue?: number;
|
||||
/** 灰色最大值(默认 255) */
|
||||
grayMaxValue?: number;
|
||||
/** 最大颜色差异(默认 25) */
|
||||
maxColorDiff?: number;
|
||||
/** alpha 通道值(默认 255) */
|
||||
alpha?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪白边/透明边选项
|
||||
*/
|
||||
export interface TrimOptions {
|
||||
/** 白色阈值,RGB 都大于此值视为白色(默认 240) */
|
||||
whiteThreshold?: number;
|
||||
/** 透明度阈值,alpha 小于此值视为透明(默认 10) */
|
||||
alphaThreshold?: number;
|
||||
/** 裁剪后最小尺寸(默认 10) */
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片裁剪输出选项
|
||||
*/
|
||||
export interface TrimOutputOptions extends TrimOptions {
|
||||
/** 输出格式(默认 'image/jpeg') */
|
||||
outputFormat?: 'image/jpeg' | 'image/png';
|
||||
/** 输出质量(默认 0.92) */
|
||||
outputQuality?: number;
|
||||
}
|
||||
67
packages/utils/src/media/video.test.ts
Normal file
67
packages/utils/src/media/video.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Video Utilities Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateThumbnailSize, formatVideoTimestamp } from './video';
|
||||
|
||||
describe('calculateThumbnailSize', () => {
|
||||
it('should handle landscape video (width > height)', () => {
|
||||
const result = calculateThumbnailSize(1920, 1080, 400);
|
||||
expect(result.width).toBe(400);
|
||||
expect(result.height).toBe(225);
|
||||
});
|
||||
|
||||
it('should handle portrait video (height > width)', () => {
|
||||
const result = calculateThumbnailSize(1080, 1920, 400);
|
||||
expect(result.width).toBe(225);
|
||||
expect(result.height).toBe(400);
|
||||
});
|
||||
|
||||
it('should handle square video', () => {
|
||||
const result = calculateThumbnailSize(1000, 1000, 400);
|
||||
expect(result.width).toBe(400);
|
||||
expect(result.height).toBe(400);
|
||||
});
|
||||
|
||||
it('should round dimensions to integers', () => {
|
||||
const result = calculateThumbnailSize(1920, 1080, 333);
|
||||
expect(Number.isInteger(result.width)).toBe(true);
|
||||
expect(Number.isInteger(result.height)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle small maxSize', () => {
|
||||
const result = calculateThumbnailSize(1920, 1080, 100);
|
||||
expect(result.width).toBe(100);
|
||||
expect(result.height).toBe(56);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatVideoTimestamp', () => {
|
||||
it('should format seconds correctly', () => {
|
||||
expect(formatVideoTimestamp(3.2)).toBe('0:03.2');
|
||||
});
|
||||
|
||||
it('should format minutes correctly', () => {
|
||||
expect(formatVideoTimestamp(65.5)).toBe('1:05.5');
|
||||
});
|
||||
|
||||
it('should format longer durations', () => {
|
||||
expect(formatVideoTimestamp(125)).toBe('2:05.0');
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
expect(formatVideoTimestamp(0)).toBe('0:00.0');
|
||||
});
|
||||
|
||||
it('should handle exact minutes', () => {
|
||||
expect(formatVideoTimestamp(60)).toBe('1:00.0');
|
||||
});
|
||||
|
||||
it('should pad seconds correctly', () => {
|
||||
expect(formatVideoTimestamp(61.5)).toBe('1:01.5');
|
||||
});
|
||||
});
|
||||
|
||||
// Note: generateVideoThumbnailFromBlob, extractVideoFrame, extractFirstFrame, extractLastFrame
|
||||
// require DOM environment (video element, canvas) and are better tested in E2E tests
|
||||
374
packages/utils/src/media/video.ts
Normal file
374
packages/utils/src/media/video.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Video Utilities
|
||||
*
|
||||
* 视频处理工具函数
|
||||
* - 视频缩略图生成
|
||||
* - 视频帧提取
|
||||
* - 时间戳格式化
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExtractedFrame,
|
||||
ExtractFrameOptions,
|
||||
ThumbnailSize,
|
||||
} from './types';
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
const DEFAULT_THUMBNAIL_QUALITY = 0.8;
|
||||
const DEFAULT_VIDEO_SEEK_TIME = 0.1; // 0.1秒处,避免完全黑屏
|
||||
const DEFAULT_FRAME_QUALITY = 0.92;
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
/**
|
||||
* 计算缩略图尺寸(保持宽高比)
|
||||
*
|
||||
* @param originalWidth - 原始宽度
|
||||
* @param originalHeight - 原始高度
|
||||
* @param maxSize - 最大尺寸
|
||||
* @returns 计算后的尺寸
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* calculateThumbnailSize(1920, 1080, 400);
|
||||
* // { width: 400, height: 225 }
|
||||
*
|
||||
* calculateThumbnailSize(1080, 1920, 400);
|
||||
* // { width: 225, height: 400 }
|
||||
* ```
|
||||
*/
|
||||
export function calculateThumbnailSize(
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
maxSize: number
|
||||
): ThumbnailSize {
|
||||
const aspectRatio = originalWidth / originalHeight;
|
||||
|
||||
let width = maxSize;
|
||||
let height = maxSize;
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
// 横向视频
|
||||
height = maxSize / aspectRatio;
|
||||
} else {
|
||||
// 纵向视频
|
||||
width = maxSize * aspectRatio;
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 视频缩略图 ====================
|
||||
|
||||
/**
|
||||
* 从视频 Blob 生成缩略图
|
||||
*
|
||||
* 在主线程中使用 video 元素和 canvas 生成预览图。
|
||||
* 注意:此函数需要 DOM 环境,不能在 Service Worker 中使用。
|
||||
*
|
||||
* @param blob - 视频 Blob
|
||||
* @param maxSize - 最大尺寸(默认 400)
|
||||
* @returns 缩略图 Blob(JPEG 格式)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const videoBlob = await fetch('/video.mp4').then(r => r.blob());
|
||||
* const thumbnail = await generateVideoThumbnailFromBlob(videoBlob, 200);
|
||||
* // thumbnail 是一个 JPEG 格式的 Blob
|
||||
* ```
|
||||
*/
|
||||
export async function generateVideoThumbnailFromBlob(
|
||||
blob: Blob,
|
||||
maxSize = 400
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const videoUrl = URL.createObjectURL(blob);
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
let isResolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('error', handleError);
|
||||
video.src = '';
|
||||
video.load();
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
try {
|
||||
video.currentTime = DEFAULT_VIDEO_SEEK_TIME;
|
||||
} catch (error) {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
reject(new Error('Failed to seek video'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
if (isResolved) return;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
const { width, height } = calculateThumbnailSize(
|
||||
video.videoWidth,
|
||||
video.videoHeight,
|
||||
maxSize
|
||||
);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(thumbnailBlob) => {
|
||||
if (isResolved) return;
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
|
||||
if (thumbnailBlob) {
|
||||
resolve(thumbnailBlob);
|
||||
} else {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
DEFAULT_THUMBNAIL_QUALITY
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Failed to generate thumbnail')
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
reject(new Error('Failed to load video'));
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('seeked', handleSeeked);
|
||||
video.addEventListener('error', handleError);
|
||||
|
||||
// 设置超时
|
||||
const timeout = setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
reject(new Error('Video thumbnail generation timeout'));
|
||||
}
|
||||
}, DEFAULT_TIMEOUT);
|
||||
|
||||
// 开始加载视频
|
||||
video.src = videoUrl;
|
||||
|
||||
// 清理超时
|
||||
const originalResolve = resolve;
|
||||
const originalReject = reject;
|
||||
resolve = (value) => {
|
||||
clearTimeout(timeout);
|
||||
originalResolve(value);
|
||||
};
|
||||
reject = (error) => {
|
||||
clearTimeout(timeout);
|
||||
originalReject(error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 视频帧提取 ====================
|
||||
|
||||
/**
|
||||
* 从视频 URL 提取指定帧
|
||||
*
|
||||
* @param videoUrl - 视频 URL(可以是远程 URL 或 blob URL)
|
||||
* @param options - 提取选项
|
||||
* @returns 提取的帧数据
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 提取最后一帧
|
||||
* const frame = await extractVideoFrame('/video.mp4');
|
||||
*
|
||||
* // 提取第一帧
|
||||
* const firstFrame = await extractVideoFrame('/video.mp4', { timestamp: 'first' });
|
||||
*
|
||||
* // 提取第 5 秒的帧
|
||||
* const frame5s = await extractVideoFrame('/video.mp4', { timestamp: 5 });
|
||||
* ```
|
||||
*/
|
||||
export async function extractVideoFrame(
|
||||
videoUrl: string,
|
||||
options: ExtractFrameOptions = {}
|
||||
): Promise<ExtractedFrame> {
|
||||
const {
|
||||
timestamp = 'last',
|
||||
format = 'image/png',
|
||||
quality = DEFAULT_FRAME_QUALITY,
|
||||
timeout = DEFAULT_TIMEOUT,
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutId: number | null = null;
|
||||
let isResolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
video.removeEventListener('loadedmetadata', handleMetadata);
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('error', handleError);
|
||||
video.src = '';
|
||||
video.load();
|
||||
};
|
||||
|
||||
const resolveWithFrame = () => {
|
||||
if (isResolved) return;
|
||||
isResolved = true;
|
||||
|
||||
try {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const dataUrl = canvas.toDataURL(format, quality);
|
||||
|
||||
cleanup();
|
||||
resolve({
|
||||
dataUrl,
|
||||
timestamp: video.currentTime,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
});
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(new Error(`Failed to extract frame: ${error}`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadata = () => {
|
||||
const duration = video.duration;
|
||||
|
||||
let targetTime: number;
|
||||
if (timestamp === 'last') {
|
||||
targetTime = Math.max(0, duration - 0.1);
|
||||
} else if (timestamp === 'first') {
|
||||
targetTime = 0.1;
|
||||
} else {
|
||||
targetTime = Math.min(Math.max(0, timestamp), duration);
|
||||
}
|
||||
|
||||
video.currentTime = targetTime;
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
resolveWithFrame();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to load video: ${video.error?.message || 'Unknown error'}`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
cleanup();
|
||||
reject(new Error(`Video frame extraction timed out after ${timeout}ms`));
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
video.addEventListener('loadedmetadata', handleMetadata);
|
||||
video.addEventListener('seeked', handleSeeked);
|
||||
video.addEventListener('error', handleError);
|
||||
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'auto';
|
||||
video.src = videoUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频的最后一帧
|
||||
*
|
||||
* @param videoUrl - 视频 URL
|
||||
* @returns 最后一帧数据
|
||||
*/
|
||||
export async function extractLastFrame(videoUrl: string): Promise<ExtractedFrame> {
|
||||
return extractVideoFrame(videoUrl, { timestamp: 'last' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频的第一帧
|
||||
*
|
||||
* @param videoUrl - 视频 URL
|
||||
* @returns 第一帧数据
|
||||
*/
|
||||
export async function extractFirstFrame(videoUrl: string): Promise<ExtractedFrame> {
|
||||
return extractVideoFrame(videoUrl, { timestamp: 'first' });
|
||||
}
|
||||
|
||||
// ==================== 时间格式化 ====================
|
||||
|
||||
/**
|
||||
* 格式化时间戳为可读字符串
|
||||
*
|
||||
* @param timestamp - 时间戳(秒)
|
||||
* @returns 格式化的时间字符串 (mm:ss.s)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* formatVideoTimestamp(65.5); // "1:05.5"
|
||||
* formatVideoTimestamp(3.2); // "0:03.2"
|
||||
* formatVideoTimestamp(125); // "2:05.0"
|
||||
* ```
|
||||
*/
|
||||
export function formatVideoTimestamp(timestamp: number): string {
|
||||
const minutes = Math.floor(timestamp / 60);
|
||||
const seconds = (timestamp % 60).toFixed(1);
|
||||
return `${minutes}:${seconds.padStart(4, '0')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user