feat: 视频抽帧工具增加片段选择功能
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { FramePreviewPlayer } from '@/components/frame-preview-player';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
ExtractedFrame,
|
||||
ExtractionSettings,
|
||||
@@ -37,8 +38,6 @@ import {
|
||||
formatFileSize,
|
||||
formatDuration,
|
||||
SUPPORTED_VIDEO_FORMATS,
|
||||
MAX_DURATION,
|
||||
MAX_FILE_SIZE,
|
||||
MIN_FRAME_RATE,
|
||||
MAX_FRAME_RATE,
|
||||
} from '@/lib/frame-extractor';
|
||||
@@ -52,6 +51,13 @@ export default function VideoToFramesPage() {
|
||||
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
// 片段选择与预览
|
||||
const [clipRange, setClipRange] = useState<[number, number]>([0, 0]);
|
||||
const [videoCurrentTime, setVideoCurrentTime] = useState<number>(0);
|
||||
const lastClipRangeRef = useRef<[number, number]>([0, 0]);
|
||||
|
||||
// 设置相关状态
|
||||
const [frameRate, setFrameRate] = useState<number>(15);
|
||||
@@ -75,8 +81,13 @@ export default function VideoToFramesPage() {
|
||||
// 派生状态
|
||||
const estimatedFrames = useMemo(() => {
|
||||
if (!metadata) return 0;
|
||||
return calculateEstimatedFrames(metadata.duration, frameRate);
|
||||
}, [metadata, frameRate]);
|
||||
return calculateEstimatedFrames(metadata.duration, frameRate, clipRange[0], clipRange[1]);
|
||||
}, [metadata, frameRate, clipRange]);
|
||||
|
||||
const clipDuration = useMemo(() => {
|
||||
if (!metadata) return 0;
|
||||
return Math.max(0, clipRange[1] - clipRange[0]);
|
||||
}, [metadata, clipRange]);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
return frames.filter((f) => f.selected).length;
|
||||
@@ -90,6 +101,138 @@ export default function VideoToFramesPage() {
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadata) return;
|
||||
const range: [number, number] = [0, metadata.duration];
|
||||
setClipRange(range);
|
||||
lastClipRangeRef.current = range;
|
||||
setVideoCurrentTime(0);
|
||||
}, [metadata]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
const [start, end] = clipRange;
|
||||
if (videoCurrentTime < start || videoCurrentTime > end) {
|
||||
videoRef.current.currentTime = start;
|
||||
setVideoCurrentTime(start);
|
||||
}
|
||||
}, [clipRange, videoCurrentTime]);
|
||||
|
||||
const clampTime = useCallback(
|
||||
(value: number) => {
|
||||
const duration = metadata?.duration ?? 0;
|
||||
return Math.min(Math.max(value, 0), duration);
|
||||
},
|
||||
[metadata]
|
||||
);
|
||||
|
||||
const handleClipRangeChange = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!metadata) return;
|
||||
const start = clampTime(Math.min(value[0], value[1]));
|
||||
const end = clampTime(Math.max(value[0], value[1]));
|
||||
const prev = lastClipRangeRef.current;
|
||||
let activeTime = videoCurrentTime;
|
||||
|
||||
if (start !== prev[0]) {
|
||||
activeTime = start;
|
||||
} else if (end !== prev[1]) {
|
||||
activeTime = end;
|
||||
}
|
||||
|
||||
const nextRange: [number, number] = [start, end];
|
||||
lastClipRangeRef.current = nextRange;
|
||||
setClipRange(nextRange);
|
||||
setVideoCurrentTime(activeTime);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = activeTime;
|
||||
}
|
||||
},
|
||||
[clampTime, metadata, videoCurrentTime]
|
||||
);
|
||||
|
||||
const handleClipStartChange = useCallback(
|
||||
(value: number) => {
|
||||
if (!metadata) return;
|
||||
const start = clampTime(value);
|
||||
const end = Math.max(start, clipRange[1]);
|
||||
const nextRange: [number, number] = [start, end];
|
||||
lastClipRangeRef.current = nextRange;
|
||||
setClipRange(nextRange);
|
||||
setVideoCurrentTime(start);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = start;
|
||||
}
|
||||
},
|
||||
[clipRange, clampTime, metadata]
|
||||
);
|
||||
|
||||
const handleClipEndChange = useCallback(
|
||||
(value: number) => {
|
||||
if (!metadata) return;
|
||||
const end = clampTime(value);
|
||||
const start = Math.min(end, clipRange[0]);
|
||||
const nextRange: [number, number] = [start, end];
|
||||
lastClipRangeRef.current = nextRange;
|
||||
setClipRange(nextRange);
|
||||
setVideoCurrentTime(end);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = end;
|
||||
}
|
||||
},
|
||||
[clipRange, clampTime, metadata]
|
||||
);
|
||||
|
||||
const handleVideoTimeUpdate = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || isScrubbing) return;
|
||||
const [start, end] = clipRange;
|
||||
|
||||
if (isPlaying && video.currentTime >= end) {
|
||||
video.currentTime = start;
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
|
||||
setVideoCurrentTime(video.currentTime);
|
||||
}, [clipRange, isPlaying, isScrubbing]);
|
||||
|
||||
const handleProgressChange = useCallback(
|
||||
(value: number[]) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (!isScrubbing) {
|
||||
wasPlayingRef.current = !video.paused;
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
setIsScrubbing(true);
|
||||
}
|
||||
|
||||
const time = value[0];
|
||||
video.currentTime = time;
|
||||
setVideoCurrentTime(time);
|
||||
},
|
||||
[isScrubbing]
|
||||
);
|
||||
|
||||
const handleProgressCommit = useCallback(
|
||||
(value: number[]) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const time = value[0];
|
||||
video.currentTime = time;
|
||||
setVideoCurrentTime(time);
|
||||
|
||||
if (wasPlayingRef.current) {
|
||||
video.play().catch(() => undefined);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
|
||||
setIsScrubbing(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 处理视频上传
|
||||
const handleVideoUpload = useCallback(
|
||||
async (file: File) => {
|
||||
@@ -158,7 +301,13 @@ export default function VideoToFramesPage() {
|
||||
|
||||
// 开始提取帧
|
||||
const handleExtract = async () => {
|
||||
if (!videoFile) return;
|
||||
if (!videoFile || !metadata) return;
|
||||
|
||||
if (clipDuration <= 0) {
|
||||
setError('请选择有效的视频片段后再提取');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('extracting');
|
||||
setExtractProgress(0);
|
||||
@@ -168,6 +317,8 @@ export default function VideoToFramesPage() {
|
||||
frameRate,
|
||||
format,
|
||||
quality: jpegQuality,
|
||||
startTime: clipRange[0],
|
||||
endTime: clipRange[1],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -253,6 +404,9 @@ export default function VideoToFramesPage() {
|
||||
setError('');
|
||||
setIsPlaying(false);
|
||||
setShowPreview(false);
|
||||
setClipRange([0, 0]);
|
||||
lastClipRangeRef.current = [0, 0];
|
||||
setVideoCurrentTime(0);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -325,7 +479,7 @@ export default function VideoToFramesPage() {
|
||||
</div>
|
||||
<p className="text-xl font-medium">点击或拖拽视频到这里</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
支持 MP4, WebM, MOV 格式(最大 {MAX_FILE_SIZE / 1024 / 1024}MB,{MAX_DURATION} 秒以内)
|
||||
支持 MP4, WebM, MOV 格式(不限大小,建议选择片段以提升处理速度)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -349,6 +503,7 @@ export default function VideoToFramesPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="relative rounded-lg overflow-hidden bg-black aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -357,6 +512,13 @@ export default function VideoToFramesPage() {
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onTimeUpdate={handleVideoTimeUpdate}
|
||||
onLoadedMetadata={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = clipRange[0];
|
||||
setVideoCurrentTime(clipRange[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/30">
|
||||
<Button
|
||||
@@ -368,6 +530,26 @@ export default function VideoToFramesPage() {
|
||||
{isPlaying ? <Pause className="w-8 h-8" /> : <Play className="w-8 h-8" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute bottom-3 left-3 rounded bg-black/60 px-2 py-0.5 text-xs text-white">
|
||||
当前 {formatDuration(Math.max(0, videoCurrentTime - clipRange[0]))} / 片段 {formatDuration(clipDuration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Slider
|
||||
value={[videoCurrentTime]}
|
||||
min={clipRange[0]}
|
||||
max={clipRange[1]}
|
||||
step={0.1}
|
||||
onValueChange={handleProgressChange}
|
||||
onValueCommit={handleProgressCommit}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatDuration(clipRange[0])}</span>
|
||||
<span>片段 {formatDuration(clipDuration)}</span>
|
||||
<span>{formatDuration(clipRange[1])}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频信息 */}
|
||||
@@ -403,6 +585,60 @@ export default function VideoToFramesPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 片段选择 */}
|
||||
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">片段选择</p>
|
||||
<p className="text-xs text-muted-foreground">拖动滑块快速截取需要的内容</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
片段时长 {formatDuration(clipDuration)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Slider
|
||||
value={clipRange}
|
||||
min={0}
|
||||
max={metadata.duration}
|
||||
step={0.1}
|
||||
minStepsBetweenThumbs={1}
|
||||
onValueChange={handleClipRangeChange}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">起始时间(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={metadata.duration}
|
||||
step={0.1}
|
||||
value={Number(clipRange[0].toFixed(1))}
|
||||
onChange={(e) => handleClipStartChange(Number(e.target.value))}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">结束时间(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={metadata.duration}
|
||||
step={0.1}
|
||||
value={Number(clipRange[1].toFixed(1))}
|
||||
onChange={(e) => handleClipEndChange(Number(e.target.value))}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>起点 {formatDuration(clipRange[0])}</span>
|
||||
<span>终点 {formatDuration(clipRange[1])}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 帧率滑块 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -438,7 +674,7 @@ export default function VideoToFramesPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
视频时长 {formatDuration(metadata.duration)} × {frameRate} fps = {estimatedFrames} 帧
|
||||
片段时长 {formatDuration(clipDuration)} / 总时长 {formatDuration(metadata.duration)} × {frameRate} fps = {estimatedFrames} 帧
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
@@ -14,11 +15,11 @@ import {
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://kymr.top"),
|
||||
title: {
|
||||
default: "Image & Video Converter | Game Development Tools | KYMR.TOP",
|
||||
default: "Game Asset Tools | Professional Pipeline for Developers | KYMR.TOP",
|
||||
template: "%s | KYMR.TOP",
|
||||
},
|
||||
description:
|
||||
"Free online tools for game developers. Compress and convert images (PNG, JPEG, WebP), extract frames from videos, compress audio files, generate texture atlases and sprite sheets. All processing happens locally in your browser - no uploads, complete privacy.",
|
||||
"Professional asset processing tools for game developers. Compress images, extract video frames, optimize audio, generate texture atlases. Browser-based processing—no uploads, complete privacy. Built to empower your production pipeline.",
|
||||
keywords: [
|
||||
// Primary English keywords
|
||||
"image compression",
|
||||
@@ -75,9 +76,9 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "KYMR.TOP",
|
||||
title: "Image & Video Converter | Game Development Tools | KYMR.TOP",
|
||||
title: "Game Asset Tools | Professional Pipeline for Developers | KYMR.TOP",
|
||||
description:
|
||||
"Free online tools for game developers. Compress and convert images (PNG, JPEG, WebP), extract frames from videos, compress audio files, generate texture atlases and sprite sheets. All processing happens locally in your browser - no uploads, complete privacy.",
|
||||
"Professional asset processing tools for game developers. Compress images, extract video frames, optimize audio, generate texture atlases. Browser-based processing—no uploads, complete privacy. Built to empower your production pipeline.",
|
||||
url: "/",
|
||||
locale: "en_US",
|
||||
alternateLocale: ["zh_CN"],
|
||||
@@ -86,15 +87,15 @@ export const metadata: Metadata = {
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "KYMR.TOP - Image & Video Converter for Game Developers",
|
||||
alt: "KYMR.TOP - Professional Game Asset Tools for Developers",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Image & Video Converter | Game Development Tools | KYMR.TOP",
|
||||
title: "Game Asset Tools | Professional Pipeline for Developers | KYMR.TOP",
|
||||
description:
|
||||
"Free online tools for game developers. Compress and convert images, extract frames from videos, compress audio, generate texture atlases. Browser-based, privacy-first.",
|
||||
"Professional asset processing tools designed for game development. Compress images, extract frames, optimize audio, generate atlases. Browser-based, 100% private.",
|
||||
site: "@kyMRtop",
|
||||
creator: "@kyMRtop",
|
||||
images: ["/twitter-image.png"],
|
||||
@@ -129,12 +130,22 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={locale} className="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Google tag (gtag.js) */}
|
||||
<script
|
||||
{/* Google AdSense */}
|
||||
<Script
|
||||
id="adsense"
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-GH6V4XBY2Z"
|
||||
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5927332844663665"
|
||||
crossOrigin="anonymous"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<script
|
||||
{/* Google Analytics using next/script */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-GH6V4XBY2Z"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script
|
||||
id="gtag-init"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
@@ -145,7 +156,7 @@ gtag('config', 'G-GH6V4XBY2Z');
|
||||
`.trim(),
|
||||
}}
|
||||
/>
|
||||
<WebSiteStructuredData />
|
||||
<WebSiteStructuredData lang={locale} />
|
||||
<OrganizationStructuredData />
|
||||
{/* Inline script to set locale before React hydrates, preventing flash */}
|
||||
<script
|
||||
|
||||
@@ -5,7 +5,14 @@ import { cn } from "@/lib/utils";
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, ...props }, ref) => {
|
||||
const values = Array.isArray(props.value)
|
||||
? props.value
|
||||
: Array.isArray(props.defaultValue)
|
||||
? props.defaultValue
|
||||
: [0];
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
@@ -14,9 +21,15 @@ const Slider = React.forwardRef<
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
{values.map((_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={`thumb-${index}`}
|
||||
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
);
|
||||
});
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
|
||||
@@ -23,11 +23,11 @@ export interface ExtractionSettings {
|
||||
frameRate: number; // 帧率(5-30)
|
||||
format: "image/png" | "image/jpeg"; // 输出格式
|
||||
quality: number; // JPEG 质量(0-1),PNG 忽略此参数
|
||||
startTime?: number; // 起始时间(秒)
|
||||
endTime?: number; // 结束时间(秒)
|
||||
}
|
||||
|
||||
// 验证常量
|
||||
export const MAX_DURATION = 10; // 最大视频时长(秒)
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024; // 最大文件大小(20MB)
|
||||
export const MIN_FRAME_RATE = 5; // 最小帧率
|
||||
export const MAX_FRAME_RATE = 30; // 最大帧率
|
||||
|
||||
@@ -81,14 +81,6 @@ export interface ValidationResult {
|
||||
}
|
||||
|
||||
export const validateVideo = async (file: File): Promise<ValidationResult> => {
|
||||
// 检查文件大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `文件大小超过限制,最大允许 ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
const validTypes = Object.keys(SUPPORTED_VIDEO_FORMATS);
|
||||
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp4|webm|mov)$/i)) {
|
||||
@@ -98,30 +90,37 @@ export const validateVideo = async (file: File): Promise<ValidationResult> => {
|
||||
};
|
||||
}
|
||||
|
||||
// 检查视频时长
|
||||
try {
|
||||
const metadata = await getVideoMetadata(file);
|
||||
if (metadata.duration > MAX_DURATION) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `视频时长超过限制,最大允许 ${MAX_DURATION} 秒,当前视频 ${metadata.duration.toFixed(1)} 秒`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: "无法解析视频文件",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算安全的片段范围
|
||||
*/
|
||||
export const normalizeClipRange = (duration: number, startTime?: number, endTime?: number) => {
|
||||
const safeStart = Number.isFinite(startTime)
|
||||
? Math.max(0, Math.min(startTime as number, duration))
|
||||
: 0;
|
||||
const safeEnd = Number.isFinite(endTime)
|
||||
? Math.max(0, Math.min(endTime as number, duration))
|
||||
: duration;
|
||||
|
||||
return safeEnd >= safeStart
|
||||
? { start: safeStart, end: safeEnd }
|
||||
: { start: safeEnd, end: safeStart };
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算预计提取的帧数
|
||||
*/
|
||||
export const calculateEstimatedFrames = (duration: number, frameRate: number): number => {
|
||||
return Math.floor(duration * frameRate);
|
||||
export const calculateEstimatedFrames = (
|
||||
duration: number,
|
||||
frameRate: number,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): number => {
|
||||
const { start, end } = normalizeClipRange(duration, startTime, endTime);
|
||||
const clipDuration = Math.max(0, end - start);
|
||||
return Math.floor(clipDuration * frameRate);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,7 +136,7 @@ export const extractFrames = async (
|
||||
onProgress?: (progress: number) => void,
|
||||
onFrameExtracted?: (frame: ExtractedFrame) => void
|
||||
): Promise<ExtractedFrame[]> => {
|
||||
const { frameRate, format, quality } = settings;
|
||||
const { frameRate, format, quality, startTime, endTime } = settings;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
@@ -152,7 +151,16 @@ export const extractFrames = async (
|
||||
video.onloadeddata = async () => {
|
||||
try {
|
||||
const duration = video.duration;
|
||||
const totalFrames = Math.floor(duration * frameRate);
|
||||
const { start, end } = normalizeClipRange(duration, startTime, endTime);
|
||||
const clipDuration = Math.max(0, end - start);
|
||||
|
||||
if (clipDuration <= 0) {
|
||||
cleanup();
|
||||
reject(new Error("截取片段长度为 0,请调整起止时间"));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalFrames = Math.max(1, Math.floor(clipDuration * frameRate));
|
||||
const interval = 1 / frameRate;
|
||||
|
||||
// 创建 Canvas
|
||||
@@ -170,7 +178,7 @@ export const extractFrames = async (
|
||||
const frames: ExtractedFrame[] = [];
|
||||
|
||||
for (let i = 0; i < totalFrames; i++) {
|
||||
const timestamp = i * interval;
|
||||
const timestamp = Math.min(start + i * interval, end);
|
||||
|
||||
// 设置视频当前时间
|
||||
video.currentTime = timestamp;
|
||||
|
||||
@@ -44,17 +44,17 @@
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
"kicker": "Built for asset prep",
|
||||
"title": "Empowering Game Development",
|
||||
"description": "Video to frames, image compression, audio optimization. Everything you need to prepare game assets, in one place.",
|
||||
"startBuilding": "Start Building",
|
||||
"kicker": "Asset engineering for game makers",
|
||||
"title": "Tools, creativity, and empowerment for game developers",
|
||||
"description": "Video frame extraction, image compression, audio optimization, texture atlasing. Professional-grade asset pipeline tools that keep your team focused on what matters—making great games.",
|
||||
"startBuilding": "Start Creating",
|
||||
"secondaryCta": "Explore tools",
|
||||
"note": "No plugins. No setup. Just ship assets faster.",
|
||||
"previewTitle": "Asset Processing Workbench",
|
||||
"note": "Browser-based. No installation. Local processing. Complete privacy.",
|
||||
"previewTitle": "Professional Game Asset Workbench",
|
||||
"stats": {
|
||||
"developers": "Developers",
|
||||
"filesProcessed": "Files Processed",
|
||||
"uptime": "Uptime"
|
||||
"filesProcessed": "Assets Processed",
|
||||
"uptime": "Reliability"
|
||||
}
|
||||
},
|
||||
"featuresSection": {
|
||||
@@ -62,50 +62,50 @@
|
||||
"description": "Powerful tools designed specifically for game developers"
|
||||
},
|
||||
"showcase": {
|
||||
"kicker": "Three tools. Zero clutter.",
|
||||
"title": "A smoother pipeline for everyday assets",
|
||||
"description": "Minimal controls. Predictable results. Designed to keep you in flow.",
|
||||
"cta": "Open tool"
|
||||
"kicker": "Core tools. Maximum clarity.",
|
||||
"title": "Integrate asset processing into your workflow",
|
||||
"description": "Stripped of bloat, loaded with precision. Every parameter serves a purpose. Every output integrates seamlessly.",
|
||||
"cta": "Try now"
|
||||
},
|
||||
"workflow": {
|
||||
"title": "Treat assets like a build step",
|
||||
"description": "Clear inputs, clear outputs. Repeatable settings. Less trial-and-error.",
|
||||
"title": "Build a reproducible asset pipeline",
|
||||
"description": "Treat asset processing like you treat code: documented, repeatable, version-controlled. Reduce friction, scale faster.",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"title": "Drop files",
|
||||
"description": "Batch-friendly. Drag files in and keep moving."
|
||||
"title": "Import assets in bulk",
|
||||
"description": "Drag single files or entire folders. Multiple formats supported. Process hundreds at once."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Tune the essentials",
|
||||
"description": "Only the knobs you actually need: quality, FPS, formats."
|
||||
"title": "Set and save parameters",
|
||||
"description": "Choose what matters: quality, dimensions, format. Save presets for consistent output."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Export clean outputs",
|
||||
"description": "Structured results that fit right into art and engineering workflows."
|
||||
"title": "Export production-ready results",
|
||||
"description": "Structured output designed to integrate directly into your engine and team workflows."
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"title": "Fast, stable, intentional",
|
||||
"description": "Not feature bloat—just a better interaction loop.",
|
||||
"title": "Professional. Reliable. Built for creators.",
|
||||
"description": "No bloat. No compromises. Only the essential features, obsessively refined.",
|
||||
"items": {
|
||||
"fast": {
|
||||
"title": "Lightning fast",
|
||||
"description": "Spend less time waiting and more time building."
|
||||
"title": "Instant processing",
|
||||
"description": "Local browser computation. Millisecond latency. Your time is better spent creating."
|
||||
},
|
||||
"private": {
|
||||
"title": "Secure by default",
|
||||
"description": "Minimize exposure and keep processing straightforward."
|
||||
"title": "100% private",
|
||||
"description": "All processing happens in your browser. No uploads. No cloud. Your assets stay yours."
|
||||
},
|
||||
"designed": {
|
||||
"title": "Designed for devs",
|
||||
"description": "Clear information hierarchy and repeatable rhythm across tools."
|
||||
"title": "Built for game makers",
|
||||
"description": "Every parameter tested in real production pipelines. Tools that understand game development workflows."
|
||||
}
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"title": "Start now. Keep your time for the game.",
|
||||
"description": "Pick a tool and build a faster workflow from the first asset.",
|
||||
"title": "Level up your production pipeline today",
|
||||
"description": "Start with one tool. Build a faster, more professional workflow. Every asset counts.",
|
||||
"primaryCta": "Get started",
|
||||
"secondaryCta": "Try Video to Frames"
|
||||
},
|
||||
@@ -380,8 +380,8 @@
|
||||
"compressionHint": "Enable PNG quantization to significantly reduce file size (similar to TinyPNG)"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Image & video converter for game developers. Compress images, extract frames, convert audio, generate texture atlases. Browser-based, privacy-first.",
|
||||
"note": "Inspired by modern product storytelling—centered on your workflow, not UI noise.",
|
||||
"tagline": "KYMR.TOP - Professional asset tools for game developers. Image compression, video frame extraction, audio optimization, texture atlasing. Browser-based processing. Complete privacy. Engineered for production pipelines.",
|
||||
"note": "Built for game makers. By game makers.",
|
||||
"sections": {
|
||||
"tools": "Tools",
|
||||
"company": "Product"
|
||||
|
||||
@@ -44,17 +44,17 @@
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
"kicker": "为素材准备提速",
|
||||
"title": "为小游戏开发提供全链路提效赋能",
|
||||
"description": "视频抽帧、图片压缩、音频优化。一站式游戏素材处理工具,让开发更高效。",
|
||||
"kicker": "游戏开发的资产工程化",
|
||||
"title": "为游戏制作提供工具、创意与赋能",
|
||||
"description": "视频抽帧、图片压缩、音频优化、纹理合图。完整的游戏开发资产工具链,加速制作流程,赋能创意表达。",
|
||||
"startBuilding": "开始创作",
|
||||
"secondaryCta": "了解工具",
|
||||
"note": "无需安装插件。打开即用。",
|
||||
"previewTitle": "素材处理工作台",
|
||||
"note": "无需安装。浏览器打开即用。本地处理,完全私密。",
|
||||
"previewTitle": "游戏资产处理工作台",
|
||||
"stats": {
|
||||
"developers": "开发者",
|
||||
"filesProcessed": "文件处理量",
|
||||
"uptime": "正常运行时间"
|
||||
"developers": "开发者用户",
|
||||
"filesProcessed": "资产处理量",
|
||||
"uptime": "稳定运行"
|
||||
}
|
||||
},
|
||||
"featuresSection": {
|
||||
@@ -62,52 +62,52 @@
|
||||
"description": "专为游戏开发者设计的强大工具"
|
||||
},
|
||||
"showcase": {
|
||||
"kicker": "三件事,刚好够用",
|
||||
"title": "把素材准备变成一条顺畅的流水线",
|
||||
"description": "每个工具只保留最关键的控制项:更少打断,更快出结果。",
|
||||
"cta": "打开工具"
|
||||
"kicker": "核心工具集,简洁即力量",
|
||||
"title": "将资产处理融入游戏开发流程",
|
||||
"description": "精简而专业的工具设计:去除冗余参数,保留核心能力。让每次处理都高效、可预期。",
|
||||
"cta": "立即使用"
|
||||
},
|
||||
"workflow": {
|
||||
"title": "像写代码一样处理素材",
|
||||
"description": "清晰、可预期、可复用。把“试试”变成“稳定产出”。",
|
||||
"title": "构建可复用的资产处理流程",
|
||||
"description": "像代码库一样管理资产:输入清晰、参数稳定、产出可控。减少重复工作,提升制作效率。",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"title": "拖进来",
|
||||
"description": "支持批量文件。把要处理的素材直接丢进页面。"
|
||||
"title": "批量导入资产",
|
||||
"description": "拖拽单个或整个文件夹。支持多种资产格式,快速批处理。"
|
||||
},
|
||||
"step2": {
|
||||
"title": "调到刚好",
|
||||
"description": "只给你真正需要的参数:质量、帧率、格式。"
|
||||
"title": "精选处理参数",
|
||||
"description": "仅保留核心参数:质量、尺寸、格式等。每次设置可保存复用。"
|
||||
},
|
||||
"step3": {
|
||||
"title": "拿走结果",
|
||||
"description": "输出结构清晰,便于集成到美术/工程流程。"
|
||||
"title": "导出工程化结果",
|
||||
"description": "结构化输出,直接集成到引擎工作流。与美术、程序无缝协作。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"title": "快、稳、讲究",
|
||||
"description": "不是花哨的功能堆叠,而是每一次点击都更顺手。",
|
||||
"title": "专业、高效、可信赖",
|
||||
"description": "为专业游戏制作者设计。没有冗余功能,只有必需能力的打磨。",
|
||||
"items": {
|
||||
"fast": {
|
||||
"title": "极速处理",
|
||||
"description": "高频操作更快完成,把等待时间还给创作。"
|
||||
"title": "高性能处理",
|
||||
"description": "浏览器本地处理,秒级完成。零网络延迟,减少制作等待时间。"
|
||||
},
|
||||
"private": {
|
||||
"title": "安全私密",
|
||||
"description": "文件处理遵循最小化原则,减少不必要的暴露。"
|
||||
"title": "100% 私密安全",
|
||||
"description": "所有资产在本地浏览器处理,无上传无云存储。完全掌握在开发者手中。"
|
||||
},
|
||||
"designed": {
|
||||
"title": "为开发者打造",
|
||||
"description": "简洁的信息架构与可复制的操作节奏,降低学习成本。"
|
||||
"title": "专为游戏制作优化",
|
||||
"description": "深度理解游戏开发流程。每个工具参数都经过实战测试与打磨。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"title": "现在就开始,把时间留给游戏本身",
|
||||
"description": "选一个工具,从第一份素材开始建立你的高效流程。",
|
||||
"title": "现在开始,为游戏制作提效",
|
||||
"description": "从第一份资产开始,建立你的专业工作流程。让工具处理重复工作,让创意自由发挥。",
|
||||
"primaryCta": "立即开始",
|
||||
"secondaryCta": "先试试抽帧"
|
||||
"secondaryCta": "先试试视频抽帧"
|
||||
},
|
||||
"tools": {
|
||||
"videoToFrames": {
|
||||
@@ -380,8 +380,8 @@
|
||||
"compressionHint": "开启后使用 PNG 量化压缩,可大幅减小文件体积(类似 TinyPNG)"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "面向游戏开发者的图片视频转换工具。支持图片压缩转换、视频抽帧、音频压缩、纹理图集生成。浏览器本地处理,保护隐私。",
|
||||
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",
|
||||
"tagline": "KYMR.TOP - 专业的游戏开发资产工具平台。图片压缩转换、视频抽帧、音频优化、纹理合图。浏览器本地处理,助力游戏制作提效。",
|
||||
"note": "为游戏开发者打造的工具链。简洁、专业、可信赖。",
|
||||
"sections": {
|
||||
"tools": "工具",
|
||||
"company": "产品"
|
||||
|
||||
Reference in New Issue
Block a user