Sync latest TrueGrowth updates
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled

This commit is contained in:
jiam
2026-07-08 02:03:18 +08:00
parent 52636c91ae
commit 5119ac0ef8
102 changed files with 20985 additions and 1760 deletions

View File

@@ -12,6 +12,8 @@ export * from './types';
// Video utilities
export {
calculateThumbnailSize,
buildVideoThumbnailSeekTimes,
isCanvasFrameMostlyBlack,
generateVideoThumbnailFromBlob,
extractVideoFrame,
extractFirstFrame,

View File

@@ -34,6 +34,32 @@ export interface ExtractFrameOptions {
timeout?: number;
}
/**
* 视频缩略图生成选项
*/
export interface VideoThumbnailOptions {
/** 自定义候选抽帧时间点(秒) */
seekTimes?: number[];
/** 是否跳过近似纯黑帧。默认 true */
skipMostlyBlackFrames?: boolean;
/** 找不到非黑帧时是否允许退回第一张候选帧。默认 true */
allowMostlyBlackFallback?: boolean;
/** 超时时间(毫秒)。默认 30000 */
timeout?: number;
}
/**
* 黑帧检测选项
*/
export interface BlackFrameDetectionOptions {
/** 单个像素视为黑色的亮度阈值。默认 18 */
pixelLumaThreshold?: number;
/** 视为黑帧所需的黑色像素占比。默认 0.985 */
blackPixelRatio?: number;
/** 视为黑帧的最大平均亮度。默认 12 */
maxAverageLuma?: number;
}
/**
* 缩略图尺寸
*/

View File

@@ -2,8 +2,14 @@
* Video Utilities Tests
*/
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { calculateThumbnailSize, formatVideoTimestamp } from './video';
import {
buildVideoThumbnailSeekTimes,
calculateThumbnailSize,
formatVideoTimestamp,
isCanvasFrameMostlyBlack,
} from './video';
describe('calculateThumbnailSize', () => {
it('should handle landscape video (width > height)', () => {
@@ -37,6 +43,50 @@ describe('calculateThumbnailSize', () => {
});
});
describe('buildVideoThumbnailSeekTimes', () => {
it('samples away from the first frame to avoid black intros', () => {
const result = buildVideoThumbnailSeekTimes(8);
expect(result[0]).toBeGreaterThanOrEqual(0.6);
expect(result).toContain(1.2);
expect(result).toContain(2);
expect(result).toContain(4);
});
it('keeps short-video samples inside duration', () => {
const result = buildVideoThumbnailSeekTimes(1);
expect(result.length).toBeGreaterThan(0);
expect(result.every((time) => time >= 0 && time < 1)).toBe(true);
});
});
describe('isCanvasFrameMostlyBlack', () => {
const createContext = (rgb: [number, number, number]) =>
({
getImageData: () => ({
data: new Uint8ClampedArray(
Array.from({ length: 8 * 8 * 4 }, (_unused, index) => {
if (index % 4 === 3) {
return 255;
}
return rgb[index % 4];
})
),
}),
} as unknown as CanvasRenderingContext2D);
it('detects nearly black frames', () => {
expect(isCanvasFrameMostlyBlack(createContext([0, 0, 0]), 8, 8)).toBe(true);
});
it('does not reject visible frames', () => {
expect(isCanvasFrameMostlyBlack(createContext([255, 120, 40]), 8, 8)).toBe(
false
);
});
});
describe('formatVideoTimestamp', () => {
it('should format seconds correctly', () => {
expect(formatVideoTimestamp(3.2)).toBe('0:03.2');

View File

@@ -8,9 +8,11 @@
*/
import type {
BlackFrameDetectionOptions,
ExtractedFrame,
ExtractFrameOptions,
ThumbnailSize,
VideoThumbnailOptions,
} from './types';
// ==================== 常量 ====================
@@ -19,6 +21,9 @@ 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;
const DEFAULT_BLACK_PIXEL_LUMA_THRESHOLD = 18;
const DEFAULT_BLACK_PIXEL_RATIO = 0.985;
const DEFAULT_BLACK_FRAME_AVERAGE_LUMA = 12;
// ==================== 辅助函数 ====================
@@ -63,6 +68,118 @@ export function calculateThumbnailSize(
};
}
function clampVideoTime(value: number, duration: number): number {
if (!Number.isFinite(duration) || duration <= 0) {
return Math.max(0, value);
}
return Math.min(Math.max(0, value), Math.max(0, duration - 0.05));
}
function uniqueSeekTimes(values: number[]): number[] {
const result: number[] = [];
values.forEach((value) => {
if (!Number.isFinite(value) || value < 0) {
return;
}
const rounded = Math.round(value * 100) / 100;
if (result.every((item) => Math.abs(item - rounded) > 0.04)) {
result.push(rounded);
}
});
return result;
}
export function buildVideoThumbnailSeekTimes(duration?: number): number[] {
if (!Number.isFinite(duration) || !duration || duration <= 0) {
return [0.6, 1.2, 2, 0.12];
}
const safeDuration = duration;
return uniqueSeekTimes([
clampVideoTime(Math.max(0.6, safeDuration * 0.12), safeDuration),
clampVideoTime(1.2, safeDuration),
clampVideoTime(2, safeDuration),
clampVideoTime(safeDuration * 0.25, safeDuration),
clampVideoTime(safeDuration * 0.5, safeDuration),
clampVideoTime(safeDuration * 0.75, safeDuration),
clampVideoTime(0.12, safeDuration),
]);
}
export function isCanvasFrameMostlyBlack(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
options: BlackFrameDetectionOptions = {}
): boolean {
if (!width || !height) {
return false;
}
const pixelLumaThreshold =
options.pixelLumaThreshold ?? DEFAULT_BLACK_PIXEL_LUMA_THRESHOLD;
const blackPixelRatio = options.blackPixelRatio ?? DEFAULT_BLACK_PIXEL_RATIO;
const maxAverageLuma =
options.maxAverageLuma ?? DEFAULT_BLACK_FRAME_AVERAGE_LUMA;
try {
const imageData = ctx.getImageData(0, 0, width, height).data;
const stepX = Math.max(1, Math.floor(width / 48));
const stepY = Math.max(1, Math.floor(height / 48));
let total = 0;
let black = 0;
let lumaSum = 0;
for (let y = 0; y < height; y += stepY) {
for (let x = 0; x < width; x += stepX) {
const index = (y * width + x) * 4;
const alpha = imageData[index + 3] ?? 255;
const luma =
alpha < 8
? 0
: imageData[index] * 0.299 +
imageData[index + 1] * 0.587 +
imageData[index + 2] * 0.114;
total += 1;
lumaSum += luma;
if (luma <= pixelLumaThreshold) {
black += 1;
}
}
}
if (!total) {
return false;
}
return (
black / total >= blackPixelRatio && lumaSum / total <= maxAverageLuma
);
} catch {
return false;
}
}
function canvasToJpegBlob(
canvas: HTMLCanvasElement,
quality: number
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(thumbnailBlob) => {
if (thumbnailBlob) {
resolve(thumbnailBlob);
} else {
reject(new Error('Failed to convert canvas to blob'));
}
},
'image/jpeg',
quality
);
});
}
// ==================== 视频缩略图 ====================
/**
@@ -84,7 +201,8 @@ export function calculateThumbnailSize(
*/
export async function generateVideoThumbnailFromBlob(
blob: Blob,
maxSize = 400
maxSize = 400,
options: VideoThumbnailOptions = {}
): Promise<Blob> {
return new Promise((resolve, reject) => {
const videoUrl = URL.createObjectURL(blob);
@@ -94,32 +212,77 @@ export async function generateVideoThumbnailFromBlob(
video.playsInline = true;
let isResolved = false;
let isCapturing = false;
let seekTimes: number[] = [];
let seekIndex = 0;
let fallbackBlob: Blob | null = null;
const cleanup = () => {
URL.revokeObjectURL(videoUrl);
video.removeEventListener('loadeddata', handleLoadedData);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
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 finish = (thumbnailBlob: Blob) => {
if (isResolved) {
return;
}
isResolved = true;
cleanup();
resolve(thumbnailBlob);
};
const fail = (error: Error) => {
if (isResolved) {
return;
}
isResolved = true;
cleanup();
reject(error);
};
const seekNextFrame = () => {
if (isResolved) {
return;
}
if (seekIndex >= seekTimes.length) {
if (fallbackBlob && options.allowMostlyBlackFallback !== false) {
finish(fallbackBlob);
return;
}
fail(new Error('Failed to find a usable video frame'));
return;
}
const targetTime = seekTimes[seekIndex];
seekIndex += 1;
try {
if (Math.abs(video.currentTime - targetTime) < 0.03) {
void captureCurrentFrame();
} else {
video.currentTime = targetTime;
}
} catch {
seekNextFrame();
}
};
const handleSeeked = () => {
if (isResolved) return;
const captureCurrentFrame = async () => {
if (isResolved || isCapturing) {
return;
}
isCapturing = true;
try {
const { width, height } = calculateThumbnailSize(
video.videoWidth,
video.videoHeight,
maxSize
);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
@@ -127,54 +290,58 @@ export async function generateVideoThumbnailFromBlob(
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',
const isMostlyBlack =
options.skipMostlyBlackFrames !== false &&
isCanvasFrameMostlyBlack(ctx, width, height);
const thumbnailBlob = await canvasToJpegBlob(
canvas,
DEFAULT_THUMBNAIL_QUALITY
);
} catch (error) {
if (!isResolved) {
isResolved = true;
cleanup();
reject(
error instanceof Error
? error
: new Error('Failed to generate thumbnail')
);
if (!fallbackBlob) {
fallbackBlob = thumbnailBlob;
}
if (!isMostlyBlack) {
finish(thumbnailBlob);
return;
}
} catch (error) {
fail(
error instanceof Error
? error
: new Error('Failed to generate thumbnail')
);
return;
} finally {
isCapturing = false;
}
seekNextFrame();
};
const handleLoadedMetadata = () => {
seekTimes = options.seekTimes?.length
? uniqueSeekTimes(options.seekTimes)
: buildVideoThumbnailSeekTimes(video.duration);
if (seekTimes.length === 0) {
seekTimes = [DEFAULT_VIDEO_SEEK_TIME];
}
seekNextFrame();
};
const handleSeeked = () => {
void captureCurrentFrame();
};
const handleError = () => {
if (!isResolved) {
isResolved = true;
cleanup();
reject(new Error('Failed to load video'));
}
fail(new Error('Failed to load video'));
};
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('error', handleError);
@@ -185,10 +352,11 @@ export async function generateVideoThumbnailFromBlob(
cleanup();
reject(new Error('Video thumbnail generation timeout'));
}
}, DEFAULT_TIMEOUT);
}, options.timeout ?? DEFAULT_TIMEOUT);
// 开始加载视频
video.src = videoUrl;
video.load();
// 清理超时
const originalResolve = resolve;
@@ -316,7 +484,9 @@ export async function extractVideoFrame(
timeoutId = window.setTimeout(() => {
if (!isResolved) {
cleanup();
reject(new Error(`Video frame extraction timed out after ${timeout}ms`));
reject(
new Error(`Video frame extraction timed out after ${timeout}ms`)
);
}
}, timeout);
@@ -338,7 +508,9 @@ export async function extractVideoFrame(
* @param videoUrl - 视频 URL
* @returns 最后一帧数据
*/
export async function extractLastFrame(videoUrl: string): Promise<ExtractedFrame> {
export async function extractLastFrame(
videoUrl: string
): Promise<ExtractedFrame> {
return extractVideoFrame(videoUrl, { timestamp: 'last' });
}
@@ -348,7 +520,9 @@ export async function extractLastFrame(videoUrl: string): Promise<ExtractedFrame
* @param videoUrl - 视频 URL
* @returns 第一帧数据
*/
export async function extractFirstFrame(videoUrl: string): Promise<ExtractedFrame> {
export async function extractFirstFrame(
videoUrl: string
): Promise<ExtractedFrame> {
return extractVideoFrame(videoUrl, { timestamp: 'first' });
}