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