mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 3a - video depth (22 tools) (#221)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
Reference in New Issue
Block a user