feat(tools): 2.0 phase 5 wave 3a - video depth (22 tools) (#221)

This commit is contained in:
SnapOtter
2026-06-13 10:19:00 +08:00
parent 2f39e38162
commit 5f98b48593
109 changed files with 12086 additions and 838 deletions
@@ -0,0 +1,92 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Target = "16:9" | "9:16" | "1:1" | "4:3" | "3:4";
export function AspectPadSettings() {
const { t } = useTranslation();
const s = t.toolSettings["aspect-pad"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("aspect-pad");
const [target, setTarget] = useState<Target>("9:16");
const [color, setColor] = useState("#000000");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { target, color };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="ap-target" className="text-xs text-muted-foreground">
{s.target}
</label>
<select
id="ap-target"
value={target}
onChange={(e) => setTarget(e.target.value as Target)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="1:1">1:1</option>
<option value="4:3">4:3</option>
<option value="3:4">3:4</option>
</select>
</div>
<div>
<label htmlFor="ap-color" className="text-xs text-muted-foreground">
{s.color}
</label>
<input
id="ap-color"
type="text"
maxLength={7}
placeholder="#RRGGBB"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.colorHint}</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="aspect-pad-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,92 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Target = "16:9" | "9:16" | "1:1" | "4:3" | "3:4";
export function BlurPadSettings() {
const { t } = useTranslation();
const s = t.toolSettings["blur-pad"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("blur-pad");
const [target, setTarget] = useState<Target>("16:9");
const [blur, setBlur] = useState(20);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { target, blur };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="bp-target" className="text-xs text-muted-foreground">
{s.target}
</label>
<select
id="bp-target"
value={target}
onChange={(e) => setTarget(e.target.value as Target)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="1:1">1:1</option>
<option value="4:3">4:3</option>
<option value="3:4">3:4</option>
</select>
</div>
<div>
<label htmlFor="bp-blur" className="text-xs text-muted-foreground">
{s.blur}
</label>
<input
id="bp-blur"
type="number"
min={2}
max={50}
step={1}
value={blur}
onChange={(e) => setBlur(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="blur-pad-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function BurnSubtitlesSettings() {
const { t } = useTranslation();
const s = t.toolSettings["burn-subtitles"];
const { files } = useFileStore();
const { processFiles, processing, error, progress } = useToolProcessor("burn-subtitles");
const [fontSize, setFontSize] = useState(24);
const hasEnough = files.length >= 2;
const handleProcess = () => {
processFiles(files, { fontSize });
};
return (
<div className="space-y-4">
<div>
<label htmlFor="bs-font-size" className="text-xs text-muted-foreground">
{s.fontSize}
</label>
<input
id="bs-font-size"
type="number"
min={8}
max={72}
step={1}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="burn-subtitles-submit"
onClick={handleProcess}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function ChangeFpsSettings() {
const { t } = useTranslation();
const s = t.toolSettings["change-fps"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("change-fps");
const [fps, setFps] = useState(30);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { fps };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="cfps-fps" className="text-xs text-muted-foreground">
{s.fps}
</label>
<input
id="cfps-fps"
type="number"
min={1}
max={120}
step={1}
value={fps}
onChange={(e) => setFps(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="change-fps-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,115 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function CropVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["crop-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("crop-video");
const [width, setWidth] = useState<number | "">("");
const [height, setHeight] = useState<number | "">("");
const [x, setX] = useState(0);
const [y, setY] = useState(0);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const dimsValid = width !== "" && height !== "";
const handleProcess = () => {
const settings = { width, height, x, y };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="crv-width" className="text-xs text-muted-foreground">
{s.width}
</label>
<input
id="crv-width"
type="number"
min={16}
value={width}
onChange={(e) => setWidth(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="crv-height" className="text-xs text-muted-foreground">
{s.height}
</label>
<input
id="crv-height"
type="number"
min={16}
value={height}
onChange={(e) => setHeight(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="crv-x" className="text-xs text-muted-foreground">
{s.x}
</label>
<input
id="crv-x"
type="number"
min={0}
value={x}
onChange={(e) => setX(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="crv-y" className="text-xs text-muted-foreground">
{s.y}
</label>
<input
id="crv-y"
type="number"
min={0}
value={y}
onChange={(e) => setY(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="crop-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing || !dimsValid}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,62 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function EmbedSubtitlesSettings() {
const { t } = useTranslation();
const s = t.toolSettings["embed-subtitles"];
const { files } = useFileStore();
const { processFiles, processing, error, progress } = useToolProcessor("embed-subtitles");
const [language, setLanguage] = useState("eng");
const hasEnough = files.length >= 2;
const handleProcess = () => {
processFiles(files, { language });
};
return (
<div className="space-y-4">
<div>
<label htmlFor="es-language" className="text-xs text-muted-foreground">
{s.language}
</label>
<input
id="es-language"
type="text"
maxLength={3}
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.languageHint}</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="embed-subtitles-submit"
onClick={handleProcess}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,52 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function ExtractSubtitlesSettings() {
const { t } = useTranslation();
const s = t.toolSettings["extract-subtitles"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("extract-subtitles");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="extract-subtitles-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,72 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Format = "mp4" | "webm";
export function GifToVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["gif-to-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("gif-to-video");
const [fmt, setFmt] = useState<Format>("mp4");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { format: fmt };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="g2v-format" className="text-xs text-muted-foreground">
{s.format}
</label>
<select
id="g2v-format"
value={fmt}
onChange={(e) => setFmt(e.target.value as Format)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="gif-to-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type Resolution = "1080p" | "720p" | "square";
export function ImagesToVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["images-to-video"];
const { files } = useFileStore();
const { processFiles, processing, error, progress } = useToolProcessor("images-to-video");
const [secondsPerImage, setSecondsPerImage] = useState(2);
const [resolution, setResolution] = useState<Resolution>("720p");
const [fps, setFps] = useState(30);
const hasEnough = files.length >= 2;
const handleProcess = () => {
processFiles(files, { secondsPerImage, resolution, fps });
};
return (
<div className="space-y-4">
<div>
<label htmlFor="i2v-spi" className="text-xs text-muted-foreground">
{s.secondsPerImage}
</label>
<input
id="i2v-spi"
type="number"
min={0.5}
max={10}
step={0.5}
value={secondsPerImage}
onChange={(e) => setSecondsPerImage(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="i2v-resolution" className="text-xs text-muted-foreground">
{s.resolution}
</label>
<select
id="i2v-resolution"
value={resolution}
onChange={(e) => setResolution(e.target.value as Resolution)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="1080p">1080p (1920x1080)</option>
<option value="720p">720p (1280x720)</option>
<option value="square">Square (1080x1080)</option>
</select>
</div>
<div>
<label htmlFor="i2v-fps" className="text-xs text-muted-foreground">
{s.fps}
</label>
<input
id="i2v-fps"
type="number"
min={10}
max={60}
step={1}
value={fps}
onChange={(e) => setFps(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="images-to-video-submit"
onClick={handleProcess}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function MergeVideosSettings() {
const { t } = useTranslation();
const s = t.toolSettings["merge-videos"];
const { files } = useFileStore();
const { processFiles, processing, error, progress } = useToolProcessor("merge-videos");
const hasEnough = files.length >= 2;
const handleProcess = () => {
processFiles(files, {});
};
return (
<div className="space-y-4">
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="merge-videos-submit"
onClick={handleProcess}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function ReplaceAudioSettings() {
const { t } = useTranslation();
const s = t.toolSettings["replace-audio"];
const { files } = useFileStore();
const { processFiles, processing, error, progress } = useToolProcessor("replace-audio");
const hasEnough = files.length >= 2;
const handleProcess = () => {
processFiles(files, {});
};
return (
<div className="space-y-4">
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="replace-audio-submit"
onClick={handleProcess}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,121 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Preset = "custom" | "2160p" | "1440p" | "1080p" | "720p" | "480p" | "360p";
export function ResizeVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["resize-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("resize-video");
const [preset, setPreset] = useState<Preset>("custom");
const [width, setWidth] = useState<number | "">("");
const [height, setHeight] = useState<number | "">("");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const isCustom = preset === "custom";
const customValid = !isCustom || width !== "" || height !== "";
const handleProcess = () => {
const settings: Record<string, unknown> = { preset };
if (isCustom) {
if (width !== "") settings.width = width;
if (height !== "") settings.height = height;
}
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="rv-preset" className="text-xs text-muted-foreground">
{s.preset}
</label>
<select
id="rv-preset"
value={preset}
onChange={(e) => setPreset(e.target.value as Preset)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="custom">Custom</option>
<option value="2160p">2160p (4K)</option>
<option value="1440p">1440p (2K)</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
<option value="360p">360p</option>
</select>
</div>
{isCustom && (
<>
<div>
<label htmlFor="rv-width" className="text-xs text-muted-foreground">
{s.width}
</label>
<input
id="rv-width"
type="number"
min={16}
max={7680}
value={width}
onChange={(e) => setWidth(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="rv-height" className="text-xs text-muted-foreground">
{s.height}
</label>
<input
id="rv-height"
type="number"
min={16}
max={4320}
value={height}
onChange={(e) => setHeight(e.target.value === "" ? "" : Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
</>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="resize-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing || !customValid}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function ReverseVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["reverse-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("reverse-video");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="reverse-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,75 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Transform = "cw90" | "ccw90" | "180" | "hflip" | "vflip";
export function RotateVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["rotate-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("rotate-video");
const [transform, setTransform] = useState<Transform>("cw90");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { transform };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="rtv-transform" className="text-xs text-muted-foreground">
{s.transform}
</label>
<select
id="rtv-transform"
value={transform}
onChange={(e) => setTransform(e.target.value as Transform)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="cw90">{s.cw90}</option>
<option value="ccw90">{s.ccw90}</option>
<option value="180">{s.rotate180}</option>
<option value="hflip">{s.hflip}</option>
<option value="vflip">{s.vflip}</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="rotate-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,73 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function StabilizeVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["stabilize-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("stabilize-video");
const [smoothing, setSmoothing] = useState(15);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { smoothing };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="sv-smoothing" className="text-xs text-muted-foreground">
{s.smoothing}
</label>
<input
id="sv-smoothing"
type="number"
min={5}
max={60}
step={1}
value={smoothing}
onChange={(e) => setSmoothing(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="stabilize-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,122 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function VideoColorSettings() {
const { t } = useTranslation();
const s = t.toolSettings["video-color"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("video-color");
const [brightness, setBrightness] = useState(0);
const [contrast, setContrast] = useState(1);
const [saturation, setSaturation] = useState(1);
const [gamma, setGamma] = useState(1);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { brightness, contrast, saturation, gamma };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="vc-brightness" className="text-xs text-muted-foreground">
{s.brightness}
</label>
<input
id="vc-brightness"
type="number"
min={-1}
max={1}
step={0.05}
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="vc-contrast" className="text-xs text-muted-foreground">
{s.contrast}
</label>
<input
id="vc-contrast"
type="number"
min={0}
max={4}
step={0.1}
value={contrast}
onChange={(e) => setContrast(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="vc-saturation" className="text-xs text-muted-foreground">
{s.saturation}
</label>
<input
id="vc-saturation"
type="number"
min={0}
max={3}
step={0.1}
value={saturation}
onChange={(e) => setSaturation(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="vc-gamma" className="text-xs text-muted-foreground">
{s.gamma}
</label>
<input
id="vc-gamma"
type="number"
min={0.1}
max={10}
step={0.1}
value={gamma}
onChange={(e) => setGamma(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="video-color-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function VideoLoudnormSettings() {
const { t } = useTranslation();
const s = t.toolSettings["video-loudnorm"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("video-loudnorm");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="video-loudnorm-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function VideoMetadataSettings() {
const { t } = useTranslation();
const s = t.toolSettings["video-metadata"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("video-metadata");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<p className="text-[10px] text-muted-foreground">{s.hint}</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="video-metadata-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,82 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function VideoSpeedSettings() {
const { t } = useTranslation();
const s = t.toolSettings["video-speed"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("video-speed");
const [factor, setFactor] = useState(2);
const [keepPitch, setKeepPitch] = useState(true);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { factor, keepPitch };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="vs-factor" className="text-xs text-muted-foreground">
{s.factor}
</label>
<input
id="vs-factor"
type="number"
min={0.25}
max={4}
step={0.25}
value={factor}
onChange={(e) => setFactor(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={keepPitch}
onChange={(e) => setKeepPitch(e.target.checked)}
className="rounded"
/>
{s.keepPitch}
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="video-speed-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,128 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Mode = "all" | "nth" | "timestamps";
type ImgFormat = "png" | "jpg";
export function VideoToFramesSettings() {
const { t } = useTranslation();
const s = t.toolSettings["video-to-frames"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("video-to-frames");
const [mode, setMode] = useState<Mode>("all");
const [n, setN] = useState(10);
const [timestamps, setTimestamps] = useState("");
const [imgFormat, setImgFormat] = useState<ImgFormat>("png");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings: Record<string, unknown> = { mode, format: imgFormat };
if (mode === "nth") settings.n = n;
if (mode === "timestamps") settings.timestamps = timestamps;
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="v2f-mode" className="text-xs text-muted-foreground">
{s.mode}
</label>
<select
id="v2f-mode"
value={mode}
onChange={(e) => setMode(e.target.value as Mode)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="all">{s.modeAll}</option>
<option value="nth">{s.modeNth}</option>
<option value="timestamps">{s.modeTimestamps}</option>
</select>
</div>
{mode === "nth" && (
<div>
<label htmlFor="v2f-n" className="text-xs text-muted-foreground">
{s.n}
</label>
<input
id="v2f-n"
type="number"
min={2}
max={1000}
step={1}
value={n}
onChange={(e) => setN(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
)}
{mode === "timestamps" && (
<div>
<label htmlFor="v2f-timestamps" className="text-xs text-muted-foreground">
{s.timestamps}
</label>
<input
id="v2f-timestamps"
type="text"
value={timestamps}
onChange={(e) => setTimestamps(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.timestampsHint}</p>
</div>
)}
<div>
<label htmlFor="v2f-format" className="text-xs text-muted-foreground">
{s.format}
</label>
<select
id="v2f-format"
value={imgFormat}
onChange={(e) => setImgFormat(e.target.value as ImgFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="video-to-frames-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,116 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function VideoToWebpSettings() {
const { t } = useTranslation();
const s = t.toolSettings["video-to-webp"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("video-to-webp");
const [fps, setFps] = useState(12);
const [width, setWidth] = useState(480);
const [quality, setQuality] = useState(75);
const [loop, setLoop] = useState(true);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { fps, width, quality, loop };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="v2w-fps" className="text-xs text-muted-foreground">
{s.fps}
</label>
<input
id="v2w-fps"
type="number"
min={1}
max={30}
step={1}
value={fps}
onChange={(e) => setFps(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="v2w-width" className="text-xs text-muted-foreground">
{s.width}
</label>
<input
id="v2w-width"
type="number"
min={16}
max={1920}
step={1}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="v2w-quality" className="text-xs text-muted-foreground">
{s.quality}
</label>
<input
id="v2w-quality"
type="number"
min={1}
max={100}
step={1}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={loop}
onChange={(e) => setLoop(e.target.checked)}
className="rounded"
/>
{s.loop}
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="video-to-webp-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
@@ -0,0 +1,145 @@
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Position = "tl" | "tc" | "tr" | "l" | "c" | "r" | "bl" | "bc" | "br";
export function WatermarkVideoSettings() {
const { t } = useTranslation();
const s = t.toolSettings["watermark-video"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("watermark-video");
const [text, setText] = useState("");
const [position, setPosition] = useState<Position>("br");
const [fontSize, setFontSize] = useState(36);
const [opacity, setOpacity] = useState(0.5);
const [color, setColor] = useState("#ffffff");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { text, position, fontSize, opacity, color };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
<div className="space-y-4">
<div>
<label htmlFor="wmv-text" className="text-xs text-muted-foreground">
{s.text}
</label>
<input
id="wmv-text"
type="text"
maxLength={200}
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="wmv-position" className="text-xs text-muted-foreground">
{s.position}
</label>
<select
id="wmv-position"
value={position}
onChange={(e) => setPosition(e.target.value as Position)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="tl">Top Left</option>
<option value="tc">Top Center</option>
<option value="tr">Top Right</option>
<option value="l">Left</option>
<option value="c">Center</option>
<option value="r">Right</option>
<option value="bl">Bottom Left</option>
<option value="bc">Bottom Center</option>
<option value="br">Bottom Right</option>
</select>
</div>
<div>
<label htmlFor="wmv-font-size" className="text-xs text-muted-foreground">
{s.fontSize}
</label>
<input
id="wmv-font-size"
type="number"
min={8}
max={120}
step={1}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="wmv-opacity" className="text-xs text-muted-foreground">
{s.opacity}
</label>
<input
id="wmv-opacity"
type="number"
min={0.05}
max={1}
step={0.05}
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="wmv-color" className="text-xs text-muted-foreground">
{s.color}
</label>
<input
id="wmv-color"
type="text"
maxLength={7}
placeholder="#RRGGBB"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.colorHint}</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={s.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="watermark-video-submit"
onClick={handleProcess}
disabled={!hasFile || processing || !text}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
</button>
)}
</div>
);
}
+24
View File
@@ -1,11 +1,13 @@
import type { LucideIcon } from "lucide-react";
import {
Anchor,
AppWindow,
Archive,
AudioLines,
BookImage,
BookOpen,
Braces,
Captions,
CheckCircle2,
Code,
Columns2,
@@ -19,6 +21,7 @@ import {
Expand,
Eye,
EyeOff,
FastForward,
FileArchive,
FileAudio,
FileCode,
@@ -33,11 +36,15 @@ import {
Film,
Focus,
Frame,
GalleryHorizontalEnd,
GalleryThumbnails,
Gauge,
Globe,
Grid3x3,
Hash,
Image,
ImagePlus,
Images,
Info,
Laugh,
Layers,
@@ -47,12 +54,16 @@ import {
Lock,
LockOpen,
Maximize2,
MessageSquareText,
MicVocal,
Minimize2,
Palette,
PenLine,
PenTool,
Pipette,
QrCode,
RectangleHorizontal,
Rewind,
RotateCw,
Scaling,
ScanEye,
@@ -74,6 +85,7 @@ import {
Undo2,
UserCheck,
Video,
Volume2,
VolumeX,
Wand,
Wrench,
@@ -84,12 +96,14 @@ import {
// Only the icons actually used by tool definitions in @snapotter/shared constants.
// Avoids `import * as icons from "lucide-react"` which pulls the entire 1000+ icon library.
export const ICON_MAP: Record<string, LucideIcon> = {
Anchor,
AppWindow,
Archive,
AudioLines,
BookImage,
BookOpen,
Braces,
Captions,
CheckCircle2,
Code,
Columns2,
@@ -103,6 +117,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
Expand,
Eye,
EyeOff,
FastForward,
FileArchive,
FileAudio,
FileCode,
@@ -117,11 +132,15 @@ export const ICON_MAP: Record<string, LucideIcon> = {
Film,
Focus,
Frame,
GalleryHorizontalEnd,
GalleryThumbnails,
Gauge,
Globe,
Grid3x3,
Hash,
Image,
ImagePlus,
Images,
Info,
Laugh,
Layers,
@@ -130,12 +149,16 @@ export const ICON_MAP: Record<string, LucideIcon> = {
Lock,
LockOpen,
Maximize2,
MessageSquareText,
MicVocal,
Minimize2,
Palette,
PenLine,
PenTool,
Pipette,
QrCode,
RectangleHorizontal,
Rewind,
RotateCw,
Scaling,
Scissors,
@@ -156,6 +179,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
Undo2,
UserCheck,
Video,
Volume2,
VolumeX,
Wand,
Wrench,
+30 -1
View File
@@ -97,6 +97,28 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
"trim-video": "media-player",
"mute-video": "media-player",
"video-to-gif": "side-by-side",
"resize-video": "media-player",
"crop-video": "media-player",
"rotate-video": "media-player",
"change-fps": "media-player",
"video-color": "media-player",
"video-speed": "media-player",
"reverse-video": "media-player",
"video-loudnorm": "media-player",
"aspect-pad": "media-player",
"blur-pad": "media-player",
"watermark-video": "media-player",
"stabilize-video": "media-player",
"gif-to-video": "media-player",
"video-to-webp": "side-by-side",
"video-to-frames": "no-comparison",
"merge-videos": "media-player",
"replace-audio": "media-player",
"burn-subtitles": "media-player",
"embed-subtitles": "media-player",
"extract-subtitles": "no-comparison",
"images-to-video": "media-player",
"video-metadata": "no-comparison",
// Audio tools
"convert-audio": "media-player",
@@ -142,4 +164,11 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
* Tools whose selected files all post in ONE request as repeated "file" parts.
* Consumed by use-tool-processor; backend routes declare maxInputs.
*/
export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set(["merge-pdf"]);
export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set([
"merge-pdf",
"merge-videos",
"replace-audio",
"burn-subtitles",
"embed-subtitles",
"images-to-video",
]);
+151 -1
View File
@@ -5,7 +5,7 @@
* Adding a new tool means adding one entry here instead of editing a 750-line file.
*/
import { AUDIO_INPUTS, VIDEO_INPUTS } from "@snapotter/shared";
import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "@snapotter/shared";
import type React from "react";
import { lazy } from "react";
import type { Crop } from "react-image-crop";
@@ -369,6 +369,116 @@ const VideoToGifSettings = lazy(() =>
default: m.VideoToGifSettings,
})),
);
const ResizeVideoSettings = lazy(() =>
import("@/components/tools/resize-video-settings").then((m) => ({
default: m.ResizeVideoSettings,
})),
);
const CropVideoSettings = lazy(() =>
import("@/components/tools/crop-video-settings").then((m) => ({
default: m.CropVideoSettings,
})),
);
const RotateVideoSettings = lazy(() =>
import("@/components/tools/rotate-video-settings").then((m) => ({
default: m.RotateVideoSettings,
})),
);
const ChangeFpsSettings = lazy(() =>
import("@/components/tools/change-fps-settings").then((m) => ({
default: m.ChangeFpsSettings,
})),
);
const VideoColorSettings = lazy(() =>
import("@/components/tools/video-color-settings").then((m) => ({
default: m.VideoColorSettings,
})),
);
const VideoSpeedSettings = lazy(() =>
import("@/components/tools/video-speed-settings").then((m) => ({
default: m.VideoSpeedSettings,
})),
);
const ReverseVideoSettings = lazy(() =>
import("@/components/tools/reverse-video-settings").then((m) => ({
default: m.ReverseVideoSettings,
})),
);
const VideoLoudnormSettings = lazy(() =>
import("@/components/tools/video-loudnorm-settings").then((m) => ({
default: m.VideoLoudnormSettings,
})),
);
const AspectPadSettings = lazy(() =>
import("@/components/tools/aspect-pad-settings").then((m) => ({
default: m.AspectPadSettings,
})),
);
const BlurPadSettings = lazy(() =>
import("@/components/tools/blur-pad-settings").then((m) => ({
default: m.BlurPadSettings,
})),
);
const WatermarkVideoSettings = lazy(() =>
import("@/components/tools/watermark-video-settings").then((m) => ({
default: m.WatermarkVideoSettings,
})),
);
const StabilizeVideoSettings = lazy(() =>
import("@/components/tools/stabilize-video-settings").then((m) => ({
default: m.StabilizeVideoSettings,
})),
);
const GifToVideoSettings = lazy(() =>
import("@/components/tools/gif-to-video-settings").then((m) => ({
default: m.GifToVideoSettings,
})),
);
const VideoToWebpSettings = lazy(() =>
import("@/components/tools/video-to-webp-settings").then((m) => ({
default: m.VideoToWebpSettings,
})),
);
const VideoToFramesSettings = lazy(() =>
import("@/components/tools/video-to-frames-settings").then((m) => ({
default: m.VideoToFramesSettings,
})),
);
const MergeVideosSettings = lazy(() =>
import("@/components/tools/merge-videos-settings").then((m) => ({
default: m.MergeVideosSettings,
})),
);
const ReplaceAudioSettings = lazy(() =>
import("@/components/tools/replace-audio-settings").then((m) => ({
default: m.ReplaceAudioSettings,
})),
);
const BurnSubtitlesSettings = lazy(() =>
import("@/components/tools/burn-subtitles-settings").then((m) => ({
default: m.BurnSubtitlesSettings,
})),
);
const EmbedSubtitlesSettings = lazy(() =>
import("@/components/tools/embed-subtitles-settings").then((m) => ({
default: m.EmbedSubtitlesSettings,
})),
);
const ExtractSubtitlesSettings = lazy(() =>
import("@/components/tools/extract-subtitles-settings").then((m) => ({
default: m.ExtractSubtitlesSettings,
})),
);
const ImagesToVideoSettings = lazy(() =>
import("@/components/tools/images-to-video-settings").then((m) => ({
default: m.ImagesToVideoSettings,
})),
);
const VideoMetadataSettings = lazy(() =>
import("@/components/tools/video-metadata-settings").then((m) => ({
default: m.VideoMetadataSettings,
})),
);
const ConvertAudioSettings = lazy(() =>
import("@/components/tools/convert-audio-settings").then((m) => ({
default: m.ConvertAudioSettings,
@@ -653,6 +763,46 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
["trim-video", { accept: VIDEO_INPUTS.join(","), Settings: TrimVideoSettings }],
["mute-video", { accept: VIDEO_INPUTS.join(","), Settings: MuteVideoSettings }],
["video-to-gif", { accept: VIDEO_INPUTS.join(","), Settings: VideoToGifSettings }],
["resize-video", { accept: VIDEO_INPUTS.join(","), Settings: ResizeVideoSettings }],
["crop-video", { accept: VIDEO_INPUTS.join(","), Settings: CropVideoSettings }],
["rotate-video", { accept: VIDEO_INPUTS.join(","), Settings: RotateVideoSettings }],
["change-fps", { accept: VIDEO_INPUTS.join(","), Settings: ChangeFpsSettings }],
["video-color", { accept: VIDEO_INPUTS.join(","), Settings: VideoColorSettings }],
["video-speed", { accept: VIDEO_INPUTS.join(","), Settings: VideoSpeedSettings }],
["reverse-video", { accept: VIDEO_INPUTS.join(","), Settings: ReverseVideoSettings }],
["video-loudnorm", { accept: VIDEO_INPUTS.join(","), Settings: VideoLoudnormSettings }],
["aspect-pad", { accept: VIDEO_INPUTS.join(","), Settings: AspectPadSettings }],
["blur-pad", { accept: VIDEO_INPUTS.join(","), Settings: BlurPadSettings }],
["watermark-video", { accept: VIDEO_INPUTS.join(","), Settings: WatermarkVideoSettings }],
["stabilize-video", { accept: VIDEO_INPUTS.join(","), Settings: StabilizeVideoSettings }],
["gif-to-video", { accept: ".gif", Settings: GifToVideoSettings }],
["video-to-webp", { accept: VIDEO_INPUTS.join(","), Settings: VideoToWebpSettings }],
["video-to-frames", { accept: VIDEO_INPUTS.join(","), Settings: VideoToFramesSettings }],
["merge-videos", { accept: VIDEO_INPUTS.join(","), Settings: MergeVideosSettings }],
[
"replace-audio",
{
accept: `${VIDEO_INPUTS.join(",")},${AUDIO_INPUTS.join(",")}`,
Settings: ReplaceAudioSettings,
},
],
[
"burn-subtitles",
{
accept: `${VIDEO_INPUTS.join(",")},${SUBTITLE_INPUTS.join(",")}`,
Settings: BurnSubtitlesSettings,
},
],
[
"embed-subtitles",
{
accept: `${VIDEO_INPUTS.join(",")},${SUBTITLE_INPUTS.join(",")}`,
Settings: EmbedSubtitlesSettings,
},
],
["extract-subtitles", { accept: VIDEO_INPUTS.join(","), Settings: ExtractSubtitlesSettings }],
["images-to-video", { accept: IMAGE_INPUTS.join(","), Settings: ImagesToVideoSettings }],
["video-metadata", { accept: VIDEO_INPUTS.join(","), Settings: VideoMetadataSettings }],
// Audio tools
["convert-audio", { accept: AUDIO_INPUTS.join(","), Settings: ConvertAudioSettings }],