Sync latest TrueGrowth updates
This commit is contained in:
@@ -12,6 +12,8 @@ export * from './types';
|
||||
// Video utilities
|
||||
export {
|
||||
calculateThumbnailSize,
|
||||
buildVideoThumbnailSeekTimes,
|
||||
isCanvasFrameMostlyBlack,
|
||||
generateVideoThumbnailFromBlob,
|
||||
extractVideoFrame,
|
||||
extractFirstFrame,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩略图尺寸
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user