Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

View 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

File diff suppressed because it is too large Load Diff

View 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';

View 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;
}

View 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

View 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 缩略图 BlobJPEG 格式)
*
* @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')}`;
}