mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(automate): make the pipeline builder fully multi-modal (#335)
* feat(shared): add outputModality to Tool metadata for crossing tools * feat(shared): add modalityForExtension, toolInputModality, toolOutputModality * fix(pipeline): route finalize and parent jobs to the pipeline's modality pool * feat(automate): add ConvertAudioControls pipeline step (exemplar) * feat(automate): add video tool settings controls to pipelines * feat(automate): add audio tool settings controls to pipelines * feat(automate): add document tool settings controls to pipelines * feat(automate): add chart-maker settings control to pipelines * feat(automate): warn on modality-incompatible pipeline steps * feat(automate): add single-file download button for pipeline results * refactor(automate): modality-aware icons, nav handler rename, mobile size bar * test(pipeline): cover audio, document, file, and cross-modality chains * i18n(automate): translate the modality-warning tooltip * test(pipeline): gate media-pool routing assertion on ffmpeg availability
This commit is contained in:
@@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ANALYTICS_EVENTS, FEATURE_BUNDLES, TOOLS } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, FEATURE_BUNDLES, MODALITY_POOL, TOOLS } from "@snapotter/shared";
|
||||
import archiver from "archiver";
|
||||
import type { FlowJob } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -111,12 +111,22 @@ function buildPipelineFlowTree(opts: {
|
||||
parsedSteps: ParsedStep[];
|
||||
uploadKey: string;
|
||||
filename: string;
|
||||
pipelinePool: Pool;
|
||||
clientJobId?: string;
|
||||
parentId?: string;
|
||||
totalFiles?: number;
|
||||
}): { tree: FlowJob; stepJobIds: string[] } {
|
||||
const { jobId, userId, parsedSteps, uploadKey, filename, clientJobId, parentId, totalFiles } =
|
||||
opts;
|
||||
const {
|
||||
jobId,
|
||||
userId,
|
||||
parsedSteps,
|
||||
uploadKey,
|
||||
filename,
|
||||
pipelinePool,
|
||||
clientJobId,
|
||||
parentId,
|
||||
totalFiles,
|
||||
} = opts;
|
||||
const totalSteps = parsedSteps.length;
|
||||
const stepJobIds = parsedSteps.map((_: unknown, i: number) => `${jobId}-s${i}`);
|
||||
|
||||
@@ -166,17 +176,18 @@ function buildPipelineFlowTree(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
// Finalize parent: runs on image pool (lightweight DB reads + one object copy;
|
||||
// keeps the flow tree single-queue except batch parents; system pool is reserved for crons + batch manifest assembly)
|
||||
// Finalize parent: runs on the pipeline's modality pool (lightweight DB reads
|
||||
// + one object copy; steps already run on their own per-step pools; system
|
||||
// pool is reserved for crons + batch manifest assembly)
|
||||
const tree: FlowJob = {
|
||||
name: "pipeline-finalize",
|
||||
queueName: queueName("image"),
|
||||
queueName: queueName(pipelinePool),
|
||||
data: {
|
||||
kind: "pipeline-finalize",
|
||||
jobId,
|
||||
toolId: "pipeline",
|
||||
userId,
|
||||
pool: "image" as Pool,
|
||||
pool: pipelinePool,
|
||||
totalSteps,
|
||||
clientJobId,
|
||||
parentId,
|
||||
@@ -197,7 +208,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
* POST /api/v1/pipeline/execute
|
||||
*
|
||||
* Accepts multipart with:
|
||||
* - A file part (the image to process)
|
||||
* - A file part (the file to process)
|
||||
* - A "pipeline" field containing JSON: { steps: [{ toolId, settings }, ...] }
|
||||
*
|
||||
* Enqueues a BullMQ FlowProducer tree (nested children for sequential
|
||||
@@ -431,6 +442,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
});
|
||||
}
|
||||
|
||||
// Derive the pipeline's pool from the first step's modality so the
|
||||
// finalize job and parent row land on the correct queue.
|
||||
const firstModality =
|
||||
TOOLS.find((t) => t.id === parsedSteps[0].resolvedToolId)?.modality ?? "image";
|
||||
const pipelinePool: Pool = MODALITY_POOL[firstModality];
|
||||
|
||||
// Build the nested FlowJob tree
|
||||
const { tree, stepJobIds } = buildPipelineFlowTree({
|
||||
jobId,
|
||||
@@ -438,6 +455,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
parsedSteps,
|
||||
uploadKey,
|
||||
filename,
|
||||
pipelinePool,
|
||||
clientJobId: clientJobId ?? jobId,
|
||||
});
|
||||
|
||||
@@ -461,7 +479,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
id: jobId,
|
||||
userId,
|
||||
toolId: "pipeline",
|
||||
pool: "image",
|
||||
pool: pipelinePool,
|
||||
type: "pipeline",
|
||||
status: "queued",
|
||||
inputRefs: [],
|
||||
@@ -476,7 +494,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
|
||||
// Wait for the finalize job (pipelines block to completion)
|
||||
try {
|
||||
const result = await waitForJob("image", jobId, 10 * 60_000);
|
||||
const result = await waitForJob(pipelinePool, jobId, 10 * 60_000);
|
||||
|
||||
if (!result) {
|
||||
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
|
||||
@@ -875,6 +893,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
// validator.
|
||||
const batchModality =
|
||||
TOOLS.find((t) => t.id === pipeline.steps[0]?.toolId)?.modality ?? "image";
|
||||
const batchPipelinePool: Pool = MODALITY_POOL[batchModality];
|
||||
const pipelineBatchScratch = join(
|
||||
tmpdir(),
|
||||
"snapotter-scratch",
|
||||
@@ -968,6 +987,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
parsedSteps,
|
||||
uploadKey,
|
||||
filename: processFilename,
|
||||
pipelinePool: batchPipelinePool,
|
||||
parentId,
|
||||
totalFiles: files.length,
|
||||
});
|
||||
@@ -990,7 +1010,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
id: perFileJobId,
|
||||
userId,
|
||||
toolId: "pipeline",
|
||||
pool: "image",
|
||||
pool: batchPipelinePool,
|
||||
type: "pipeline-finalize",
|
||||
status: "queued",
|
||||
inputRefs: [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -90,3 +90,65 @@ export function AspectPadSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AspectPadControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function AspectPadControls({ settings: initial, onChange }: AspectPadControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["aspect-pad"];
|
||||
const [target, setTarget] = useState<Target>("9:16");
|
||||
const [color, setColor] = useState("#000000");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.target != null) setTarget(initial.target as Target);
|
||||
if (initial.color != null) setColor(String(initial.color));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ target, color });
|
||||
}, [target, color]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="app-target" className="text-xs text-muted-foreground">
|
||||
{s.target}
|
||||
</label>
|
||||
<select
|
||||
id="app-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="app-color" className="text-xs text-muted-foreground">
|
||||
{s.color}
|
||||
</label>
|
||||
<input
|
||||
id="app-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -71,3 +71,49 @@ export function AudioChannelsSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AudioChannelsControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function AudioChannelsControls({ settings: initial, onChange }: AudioChannelsControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["audio-channels"];
|
||||
const [mode, setMode] = useState<ChannelMode>("stereo-to-mono");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.mode != null) setMode(initial.mode as ChannelMode);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ mode });
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="acp-mode" className="text-xs text-muted-foreground">
|
||||
{s.mode}
|
||||
</label>
|
||||
<select
|
||||
id="acp-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as ChannelMode)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="stereo-to-mono">{s["stereo-to-mono"]}</option>
|
||||
<option value="mono-to-stereo">{s["mono-to-stereo"]}</option>
|
||||
<option value="swap">{s.swap}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -111,3 +111,92 @@ export function AudioMetadataSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AudioMetadataControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function AudioMetadataControls({ settings: initial, onChange }: AudioMetadataControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["audio-metadata"];
|
||||
const [strip, setStrip] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [artist, setArtist] = useState("");
|
||||
const [album, setAlbum] = useState("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.strip != null) setStrip(Boolean(initial.strip));
|
||||
if (initial.title != null) setTitle(String(initial.title));
|
||||
if (initial.artist != null) setArtist(String(initial.artist));
|
||||
if (initial.album != null) setAlbum(String(initial.album));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
const out: Record<string, unknown> = { strip };
|
||||
if (title) out.title = title;
|
||||
if (artist) out.artist = artist;
|
||||
if (album) out.album = album;
|
||||
onChangeRef.current?.(out);
|
||||
}, [strip, title, artist, album]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={strip}
|
||||
onChange={(e) => setStrip(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{s.strip}
|
||||
</label>
|
||||
<div>
|
||||
<label htmlFor="amp-title" className="text-xs text-muted-foreground">
|
||||
{s.title}
|
||||
</label>
|
||||
<input
|
||||
id="amp-title"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(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="amp-artist" className="text-xs text-muted-foreground">
|
||||
{s.artist}
|
||||
</label>
|
||||
<input
|
||||
id="amp-artist"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={artist}
|
||||
onChange={(e) => setArtist(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="amp-album" className="text-xs text-muted-foreground">
|
||||
{s.album}
|
||||
</label>
|
||||
<input
|
||||
id="amp-album"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={album}
|
||||
onChange={(e) => setAlbum(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -69,3 +69,49 @@ export function AudioSpeedSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AudioSpeedControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function AudioSpeedControls({ settings: initial, onChange }: AudioSpeedControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["audio-speed"];
|
||||
const [factor, setFactor] = useState(1.5);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.factor != null) setFactor(Number(initial.factor));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ factor });
|
||||
}, [factor]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="asp-factor" className="text-xs text-muted-foreground">
|
||||
{s.factor}
|
||||
</label>
|
||||
<input
|
||||
id="asp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -90,3 +90,68 @@ export function BlurPadSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BlurPadControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function BlurPadControls({ settings: initial, onChange }: BlurPadControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["blur-pad"];
|
||||
const [target, setTarget] = useState<Target>("16:9");
|
||||
const [blur, setBlur] = useState(20);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.target != null) setTarget(initial.target as Target);
|
||||
if (initial.blur != null) setBlur(Number(initial.blur));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ target, blur });
|
||||
}, [target, blur]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="bpp-target" className="text-xs text-muted-foreground">
|
||||
{s.target}
|
||||
</label>
|
||||
<select
|
||||
id="bpp-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="bpp-blur" className="text-xs text-muted-foreground">
|
||||
{s.blur}
|
||||
</label>
|
||||
<input
|
||||
id="bpp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -69,3 +69,49 @@ export function ChangeFpsSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChangeFpsControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ChangeFpsControls({ settings: initial, onChange }: ChangeFpsControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["change-fps"];
|
||||
const [fps, setFps] = useState(30);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.fps != null) setFps(Number(initial.fps));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ fps });
|
||||
}, [fps]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cfpsp-fps" className="text-xs text-muted-foreground">
|
||||
{s.fps}
|
||||
</label>
|
||||
<input
|
||||
id="cfpsp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -152,3 +152,105 @@ export function ChartMakerSettings() {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type ChartKind = "bar" | "line" | "pie";
|
||||
|
||||
export interface ChartMakerControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ChartMakerControls({ settings: initial, onChange }: ChartMakerControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["chart-maker"];
|
||||
const [kind, setKind] = useState<ChartKind>("bar");
|
||||
const [title, setTitle] = useState("");
|
||||
const [width, setWidth] = useState(960);
|
||||
const [height, setHeight] = useState(540);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.kind != null) setKind(initial.kind as ChartKind);
|
||||
if (initial.title != null) setTitle(String(initial.title));
|
||||
if (initial.width != null) setWidth(Number(initial.width));
|
||||
if (initial.height != null) setHeight(Number(initial.height));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
const out: Record<string, unknown> = { kind, width, height };
|
||||
if (title.trim()) out.title = title.trim();
|
||||
onChangeRef.current?.(out);
|
||||
}, [kind, title, width, height]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cm-kind" className="text-xs text-muted-foreground">
|
||||
{s.kind}
|
||||
</label>
|
||||
<select
|
||||
id="cm-kind"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as ChartKind)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{CHART_KINDS.map((ck) => (
|
||||
<option key={ck.value} value={ck.value}>
|
||||
{ck.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="cm-title" className="text-xs text-muted-foreground">
|
||||
{s.title}
|
||||
</label>
|
||||
<input
|
||||
id="cm-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Optional chart title"
|
||||
maxLength={120}
|
||||
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="cm-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="cm-width"
|
||||
type="number"
|
||||
min={320}
|
||||
max={2048}
|
||||
step={10}
|
||||
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="cm-height" className="text-xs text-muted-foreground">
|
||||
{s.height}
|
||||
</label>
|
||||
<input
|
||||
id="cm-height"
|
||||
type="number"
|
||||
min={240}
|
||||
max={1536}
|
||||
step={10}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -90,3 +90,67 @@ export function CompressVideoSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CompressVideoControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function CompressVideoControls({ settings: initial, onChange }: CompressVideoControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["compress-video"];
|
||||
const [quality, setQuality] = useState<Quality>("balanced");
|
||||
const [resolution, setResolution] = useState<Resolution>("original");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.quality != null) setQuality(initial.quality as Quality);
|
||||
if (initial.resolution != null) setResolution(initial.resolution as Resolution);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ quality, resolution });
|
||||
}, [quality, resolution]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cpvp-quality" className="text-xs text-muted-foreground">
|
||||
{s.quality}
|
||||
</label>
|
||||
<select
|
||||
id="cpvp-quality"
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value as Quality)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="light">{s.light}</option>
|
||||
<option value="balanced">{s.balanced}</option>
|
||||
<option value="strong">{s.strong}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="cpvp-resolution" className="text-xs text-muted-foreground">
|
||||
{s.resolution}
|
||||
</label>
|
||||
<select
|
||||
id="cpvp-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="original">{s.original}</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -94,3 +94,70 @@ export function ConvertAudioSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConvertAudioControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ConvertAudioControls({ settings: initial, onChange }: ConvertAudioControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["convert-audio"];
|
||||
const [outFormat, setOutFormat] = useState<AudioFormat>("mp3");
|
||||
const [bitrateKbps, setBitrateKbps] = useState(192);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.format != null) setOutFormat(initial.format as AudioFormat);
|
||||
if (initial.bitrateKbps != null) setBitrateKbps(Number(initial.bitrateKbps));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ format: outFormat, bitrateKbps });
|
||||
}, [outFormat, bitrateKbps]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ca-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="ca-format"
|
||||
value={outFormat}
|
||||
onChange={(e) => setOutFormat(e.target.value as AudioFormat)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="wav">WAV</option>
|
||||
<option value="ogg">OGG</option>
|
||||
<option value="flac">FLAC</option>
|
||||
<option value="m4a">M4A</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ca-bitrate" className="text-xs text-muted-foreground">
|
||||
{s.bitrate}
|
||||
</label>
|
||||
<select
|
||||
id="ca-bitrate"
|
||||
value={bitrateKbps}
|
||||
onChange={(e) => setBitrateKbps(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"
|
||||
>
|
||||
{BITRATE_OPTIONS.map((br) => (
|
||||
<option key={br} value={br}>
|
||||
{br} kbps
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -89,3 +89,54 @@ export function ConvertDocumentSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConvertDocumentControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ConvertDocumentControls({
|
||||
settings: initial,
|
||||
onChange,
|
||||
}: ConvertDocumentControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["convert-document"];
|
||||
const [outFormat, setOutFormat] = useState<DocFormat>("docx");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.format != null) setOutFormat(initial.format as DocFormat);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ format: outFormat });
|
||||
}, [outFormat]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cdc-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="cdc-format"
|
||||
value={outFormat}
|
||||
onChange={(e) => setOutFormat(e.target.value as DocFormat)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{ALL_FORMATS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -87,3 +87,54 @@ export function ConvertPresentationSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConvertPresentationControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ConvertPresentationControls({
|
||||
settings: initial,
|
||||
onChange,
|
||||
}: ConvertPresentationControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["convert-presentation"];
|
||||
const [outFormat, setOutFormat] = useState<PresFormat>("pptx");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.format != null) setOutFormat(initial.format as PresFormat);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ format: outFormat });
|
||||
}, [outFormat]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cpc-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="cpc-format"
|
||||
value={outFormat}
|
||||
onChange={(e) => setOutFormat(e.target.value as PresFormat)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{ALL_FORMATS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -90,3 +90,54 @@ export function ConvertSpreadsheetSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConvertSpreadsheetControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ConvertSpreadsheetControls({
|
||||
settings: initial,
|
||||
onChange,
|
||||
}: ConvertSpreadsheetControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["convert-spreadsheet"];
|
||||
const [outFormat, setOutFormat] = useState<SheetFormat>("xlsx");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.format != null) setOutFormat(initial.format as SheetFormat);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ format: outFormat });
|
||||
}, [outFormat]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="csc-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="csc-format"
|
||||
value={outFormat}
|
||||
onChange={(e) => setOutFormat(e.target.value as SheetFormat)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{ALL_FORMATS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -89,3 +89,66 @@ export function ConvertVideoSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConvertVideoControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ConvertVideoControls({ settings: initial, onChange }: ConvertVideoControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["convert-video"];
|
||||
const [outFormat, setOutFormat] = useState<VideoFormat>("mp4");
|
||||
const [quality, setQuality] = useState<Quality>("balanced");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.format != null) setOutFormat(initial.format as VideoFormat);
|
||||
if (initial.quality != null) setQuality(initial.quality as Quality);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ format: outFormat, quality });
|
||||
}, [outFormat, quality]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cvp-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="cvp-format"
|
||||
value={outFormat}
|
||||
onChange={(e) => setOutFormat(e.target.value as VideoFormat)}
|
||||
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="mov">MOV</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="cvp-quality" className="text-xs text-muted-foreground">
|
||||
{s.quality}
|
||||
</label>
|
||||
<select
|
||||
id="cvp-quality"
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value as Quality)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="high">{s.high}</option>
|
||||
<option value="balanced">{s.balanced}</option>
|
||||
<option value="small">{s.small}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -113,3 +113,92 @@ export function CropVideoSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CropVideoControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function CropVideoControls({ settings: initial, onChange }: CropVideoControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["crop-video"];
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [x, setX] = useState(0);
|
||||
const [y, setY] = useState(0);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.width != null) setWidth(Number(initial.width));
|
||||
if (initial.height != null) setHeight(Number(initial.height));
|
||||
if (initial.x != null) setX(Number(initial.x));
|
||||
if (initial.y != null) setY(Number(initial.y));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ width, height, x, y });
|
||||
}, [width, height, x, y]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="crvp-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="crvp-width"
|
||||
type="number"
|
||||
min={0}
|
||||
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="crvp-height" className="text-xs text-muted-foreground">
|
||||
{s.height}
|
||||
</label>
|
||||
<input
|
||||
id="crvp-height"
|
||||
type="number"
|
||||
min={0}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(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="crvp-x" className="text-xs text-muted-foreground">
|
||||
{s.x}
|
||||
</label>
|
||||
<input
|
||||
id="crvp-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="crvp-y" className="text-xs text-muted-foreground">
|
||||
{s.y}
|
||||
</label>
|
||||
<input
|
||||
id="crvp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -74,3 +74,50 @@ export function EpubConvertSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EpubConvertControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function EpubConvertControls({ settings: initial, onChange }: EpubConvertControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["epub-convert"];
|
||||
const [outFormat, setOutFormat] = useState<EpubFormat>("pdf");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.format != null) setOutFormat(initial.format as EpubFormat);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ format: outFormat });
|
||||
}, [outFormat]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ecc-format" className="text-xs text-muted-foreground">
|
||||
{s.format}
|
||||
</label>
|
||||
<select
|
||||
id="ecc-format"
|
||||
value={outFormat}
|
||||
onChange={(e) => setOutFormat(e.target.value as EpubFormat)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="docx">DOCX</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="md">Markdown</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -67,3 +67,47 @@ export function ExtractPagesSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExtractPagesControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ExtractPagesControls({ settings: initial, onChange }: ExtractPagesControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["extract-pages"];
|
||||
const [range, setRange] = useState("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.range != null) setRange(String(initial.range));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ range });
|
||||
}, [range]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="epc-range" className="text-xs text-muted-foreground">
|
||||
{s.range}
|
||||
</label>
|
||||
<input
|
||||
id="epc-range"
|
||||
type="text"
|
||||
value={range}
|
||||
onChange={(e) => setRange(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.rangeHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -75,3 +75,53 @@ export function NupPdfSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NupPdfControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function NupPdfControls({ settings: initial, onChange }: NupPdfControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["nup-pdf"];
|
||||
const [perSheet, setPerSheet] = useState<PerSheet>(2);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.perSheet != null) setPerSheet(Number(initial.perSheet) as PerSheet);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ perSheet });
|
||||
}, [perSheet]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="nupc-per-sheet" className="text-xs text-muted-foreground">
|
||||
{s.perSheet}
|
||||
</label>
|
||||
<select
|
||||
id="nupc-per-sheet"
|
||||
value={perSheet}
|
||||
onChange={(e) => setPerSheet(Number(e.target.value) as PerSheet)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
<option value={8}>8</option>
|
||||
<option value={9}>9</option>
|
||||
<option value={12}>12</option>
|
||||
<option value={16}>16</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -67,3 +67,47 @@ export function OrganizePdfSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OrganizePdfControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function OrganizePdfControls({ settings: initial, onChange }: OrganizePdfControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["organize-pdf"];
|
||||
const [order, setOrder] = useState("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.order != null) setOrder(String(initial.order));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ order });
|
||||
}, [order]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="opc-order" className="text-xs text-muted-foreground">
|
||||
{s.order}
|
||||
</label>
|
||||
<input
|
||||
id="opc-order"
|
||||
type="text"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(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.orderHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -116,3 +116,97 @@ export function PdfMetadataSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PdfMetadataControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function PdfMetadataControls({ settings: initial, onChange }: PdfMetadataControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["pdf-metadata"];
|
||||
const [title, setTitle] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [keywords, setKeywords] = useState("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.title != null) setTitle(String(initial.title));
|
||||
if (initial.author != null) setAuthor(String(initial.author));
|
||||
if (initial.subject != null) setSubject(String(initial.subject));
|
||||
if (initial.keywords != null) setKeywords(String(initial.keywords));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (title) out.title = title;
|
||||
if (author) out.author = author;
|
||||
if (subject) out.subject = subject;
|
||||
if (keywords) out.keywords = keywords;
|
||||
onChangeRef.current?.(out);
|
||||
}, [title, author, subject, keywords]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="pmc-title" className="text-xs text-muted-foreground">
|
||||
{s.title}
|
||||
</label>
|
||||
<input
|
||||
id="pmc-title"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(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="pmc-author" className="text-xs text-muted-foreground">
|
||||
{s.author}
|
||||
</label>
|
||||
<input
|
||||
id="pmc-author"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(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="pmc-subject" className="text-xs text-muted-foreground">
|
||||
{s.subject}
|
||||
</label>
|
||||
<input
|
||||
id="pmc-subject"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(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="pmc-keywords" className="text-xs text-muted-foreground">
|
||||
{s.keywords}
|
||||
</label>
|
||||
<input
|
||||
id="pmc-keywords"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { TOOLS } from "@snapotter/shared";
|
||||
import { FileImage, GripVertical, X } from "lucide-react";
|
||||
import { type Modality, TOOLS } from "@snapotter/shared";
|
||||
import { AlertTriangle, GripVertical, Workflow, X } from "lucide-react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { format } from "@/lib/format";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { computeStepWarnings, type StepWarning } from "@/lib/pipeline-compat";
|
||||
import { getToolName } from "@/lib/tool-i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||
@@ -27,6 +29,7 @@ import { getSettingsSummary } from "./pipeline-step-summary";
|
||||
interface PipelineBuilderProps {
|
||||
steps: PipelineStep[];
|
||||
expandedStepId: string | null;
|
||||
uploadedModality?: Modality | null;
|
||||
onRemoveStep: (id: string) => void;
|
||||
onReorderSteps: (activeId: string, overId: string) => void;
|
||||
onUpdateSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
@@ -41,6 +44,7 @@ interface SortableStepProps {
|
||||
step: PipelineStep;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
warning?: StepWarning | null;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||
@@ -50,6 +54,7 @@ function SortableStep({
|
||||
step,
|
||||
index,
|
||||
isExpanded,
|
||||
warning,
|
||||
onToggle,
|
||||
onRemove,
|
||||
onUpdateSettings,
|
||||
@@ -67,7 +72,7 @@ function SortableStep({
|
||||
const tool = TOOLS.find((t) => t.id === step.toolId);
|
||||
if (!tool) return null;
|
||||
|
||||
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? Workflow;
|
||||
const summary = getSettingsSummary(step.toolId, step.settings);
|
||||
|
||||
return (
|
||||
@@ -115,6 +120,20 @@ function SortableStep({
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</span>
|
||||
|
||||
{/* Modality mismatch warning */}
|
||||
{warning && (
|
||||
<span
|
||||
title={format(t.automate.modalityWarningTooltip, {
|
||||
expected: warning.expects,
|
||||
received: warning.receives,
|
||||
})}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400 ms-1"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t.automate.modalityWarning}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Settings summary when collapsed */}
|
||||
{!isExpanded && summary && (
|
||||
<span className="text-xs text-muted-foreground truncate ms-1">{summary}</span>
|
||||
@@ -154,12 +173,17 @@ function SortableStep({
|
||||
export function PipelineBuilder({
|
||||
steps,
|
||||
expandedStepId,
|
||||
uploadedModality,
|
||||
onRemoveStep,
|
||||
onReorderSteps,
|
||||
onUpdateSettings,
|
||||
onToggleStep,
|
||||
}: PipelineBuilderProps) {
|
||||
const { t } = useTranslation();
|
||||
const warnings = computeStepWarnings(
|
||||
steps.map((s) => s.toolId),
|
||||
uploadedModality ?? null,
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -176,7 +200,7 @@ export function PipelineBuilder({
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<FileImage className="h-8 w-8 text-muted-foreground" />
|
||||
<Workflow className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">{t.automate.noStepsHeading}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-[240px]">{t.automate.addToolsPrompt}</p>
|
||||
@@ -194,6 +218,7 @@ export function PipelineBuilder({
|
||||
step={step}
|
||||
index={idx}
|
||||
isExpanded={expandedStepId === step.id}
|
||||
warning={warnings[idx]}
|
||||
onToggle={() => onToggleStep(expandedStepId === step.id ? null : step.id)}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
onUpdateSettings={(s) => onUpdateSettings(step.id, s)}
|
||||
|
||||
@@ -48,6 +48,116 @@ const CONTROLS: Record<string, React.LazyExoticComponent<React.FC<ControlProps>>
|
||||
"noise-removal": lazy(() =>
|
||||
import("./noise-removal-settings").then((m) => ({ default: m.NoiseRemovalControls })),
|
||||
),
|
||||
"convert-audio": lazy(() =>
|
||||
import("./convert-audio-settings").then((m) => ({ default: m.ConvertAudioControls })),
|
||||
),
|
||||
"convert-video": lazy(() =>
|
||||
import("./convert-video-settings").then((m) => ({ default: m.ConvertVideoControls })),
|
||||
),
|
||||
"compress-video": lazy(() =>
|
||||
import("./compress-video-settings").then((m) => ({ default: m.CompressVideoControls })),
|
||||
),
|
||||
"trim-video": lazy(() =>
|
||||
import("./trim-video-settings").then((m) => ({ default: m.TrimVideoControls })),
|
||||
),
|
||||
"video-to-gif": lazy(() =>
|
||||
import("./video-to-gif-settings").then((m) => ({ default: m.VideoToGifControls })),
|
||||
),
|
||||
"video-to-webp": lazy(() =>
|
||||
import("./video-to-webp-settings").then((m) => ({ default: m.VideoToWebpControls })),
|
||||
),
|
||||
"resize-video": lazy(() =>
|
||||
import("./resize-video-settings").then((m) => ({ default: m.ResizeVideoControls })),
|
||||
),
|
||||
"crop-video": lazy(() =>
|
||||
import("./crop-video-settings").then((m) => ({ default: m.CropVideoControls })),
|
||||
),
|
||||
"rotate-video": lazy(() =>
|
||||
import("./rotate-video-settings").then((m) => ({ default: m.RotateVideoControls })),
|
||||
),
|
||||
"change-fps": lazy(() =>
|
||||
import("./change-fps-settings").then((m) => ({ default: m.ChangeFpsControls })),
|
||||
),
|
||||
"video-color": lazy(() =>
|
||||
import("./video-color-settings").then((m) => ({ default: m.VideoColorControls })),
|
||||
),
|
||||
"video-speed": lazy(() =>
|
||||
import("./video-speed-settings").then((m) => ({ default: m.VideoSpeedControls })),
|
||||
),
|
||||
"aspect-pad": lazy(() =>
|
||||
import("./aspect-pad-settings").then((m) => ({ default: m.AspectPadControls })),
|
||||
),
|
||||
"blur-pad": lazy(() =>
|
||||
import("./blur-pad-settings").then((m) => ({ default: m.BlurPadControls })),
|
||||
),
|
||||
"watermark-video": lazy(() =>
|
||||
import("./watermark-video-settings").then((m) => ({ default: m.WatermarkVideoControls })),
|
||||
),
|
||||
"trim-audio": lazy(() =>
|
||||
import("./trim-audio-settings").then((m) => ({ default: m.TrimAudioControls })),
|
||||
),
|
||||
"volume-adjust": lazy(() =>
|
||||
import("./volume-adjust-settings").then((m) => ({ default: m.VolumeAdjustControls })),
|
||||
),
|
||||
"audio-speed": lazy(() =>
|
||||
import("./audio-speed-settings").then((m) => ({ default: m.AudioSpeedControls })),
|
||||
),
|
||||
"pitch-shift": lazy(() =>
|
||||
import("./pitch-shift-settings").then((m) => ({ default: m.PitchShiftControls })),
|
||||
),
|
||||
"audio-channels": lazy(() =>
|
||||
import("./audio-channels-settings").then((m) => ({ default: m.AudioChannelsControls })),
|
||||
),
|
||||
"ringtone-maker": lazy(() =>
|
||||
import("./ringtone-maker-settings").then((m) => ({ default: m.RingtoneMakerControls })),
|
||||
),
|
||||
"audio-metadata": lazy(() =>
|
||||
import("./audio-metadata-settings").then((m) => ({ default: m.AudioMetadataControls })),
|
||||
),
|
||||
"rotate-pdf": lazy(() =>
|
||||
import("./rotate-pdf-settings").then((m) => ({ default: m.RotatePdfControls })),
|
||||
),
|
||||
"convert-document": lazy(() =>
|
||||
import("./convert-document-settings").then((m) => ({ default: m.ConvertDocumentControls })),
|
||||
),
|
||||
"convert-presentation": lazy(() =>
|
||||
import("./convert-presentation-settings").then((m) => ({
|
||||
default: m.ConvertPresentationControls,
|
||||
})),
|
||||
),
|
||||
"convert-spreadsheet": lazy(() =>
|
||||
import("./convert-spreadsheet-settings").then((m) => ({
|
||||
default: m.ConvertSpreadsheetControls,
|
||||
})),
|
||||
),
|
||||
"extract-pages": lazy(() =>
|
||||
import("./extract-pages-settings").then((m) => ({ default: m.ExtractPagesControls })),
|
||||
),
|
||||
"remove-pages": lazy(() =>
|
||||
import("./remove-pages-settings").then((m) => ({ default: m.RemovePagesControls })),
|
||||
),
|
||||
"organize-pdf": lazy(() =>
|
||||
import("./organize-pdf-settings").then((m) => ({ default: m.OrganizePdfControls })),
|
||||
),
|
||||
"nup-pdf": lazy(() => import("./nup-pdf-settings").then((m) => ({ default: m.NupPdfControls }))),
|
||||
"watermark-pdf": lazy(() =>
|
||||
import("./watermark-pdf-settings").then((m) => ({ default: m.WatermarkPdfControls })),
|
||||
),
|
||||
"redact-pdf": lazy(() =>
|
||||
import("./redact-pdf-settings").then((m) => ({ default: m.RedactPdfControls })),
|
||||
),
|
||||
"pdf-metadata": lazy(() =>
|
||||
import("./pdf-metadata-settings").then((m) => ({ default: m.PdfMetadataControls })),
|
||||
),
|
||||
"epub-convert": lazy(() =>
|
||||
import("./epub-convert-settings").then((m) => ({ default: m.EpubConvertControls })),
|
||||
),
|
||||
"compress-pdf": lazy(() =>
|
||||
import("./compress-settings").then((m) => ({ default: m.CompressControls })),
|
||||
),
|
||||
"chart-maker": lazy(() =>
|
||||
import("./chart-maker-settings").then((m) => ({ default: m.ChartMakerControls })),
|
||||
),
|
||||
};
|
||||
|
||||
const COLOR_TOOL_IDS = new Set(["adjust-colors"]);
|
||||
|
||||
@@ -57,6 +57,150 @@ export function getSettingsSummary(toolId: string, settings: Record<string, unkn
|
||||
const pct = settings.intensity != null ? Math.round(Number(settings.intensity) * 100) : 100;
|
||||
return `${pct}% intensity`;
|
||||
}
|
||||
case "convert-audio": {
|
||||
if (settings.format) return String(settings.format).toUpperCase();
|
||||
return "";
|
||||
}
|
||||
case "convert-video": {
|
||||
if (settings.format) return String(settings.format).toUpperCase();
|
||||
return "";
|
||||
}
|
||||
case "compress-video": {
|
||||
if (settings.quality) return String(settings.quality);
|
||||
return "";
|
||||
}
|
||||
case "trim-video": {
|
||||
if (settings.endS != null) return `${settings.startS ?? 0}-${settings.endS}s`;
|
||||
return "";
|
||||
}
|
||||
case "video-to-gif": {
|
||||
if (settings.fps) return `${settings.fps} fps`;
|
||||
return "";
|
||||
}
|
||||
case "video-to-webp": {
|
||||
if (settings.quality != null) return `Q${settings.quality}`;
|
||||
return "";
|
||||
}
|
||||
case "resize-video": {
|
||||
if (settings.preset && settings.preset !== "custom") return String(settings.preset);
|
||||
if (settings.width && settings.height) return `${settings.width}x${settings.height}`;
|
||||
if (settings.width) return `${settings.width}px wide`;
|
||||
if (settings.height) return `${settings.height}px tall`;
|
||||
return "";
|
||||
}
|
||||
case "crop-video": {
|
||||
if (settings.width && settings.height) return `${settings.width}x${settings.height}`;
|
||||
return "";
|
||||
}
|
||||
case "rotate-video": {
|
||||
if (settings.transform) return String(settings.transform);
|
||||
return "";
|
||||
}
|
||||
case "change-fps": {
|
||||
if (settings.fps) return `${settings.fps} fps`;
|
||||
return "";
|
||||
}
|
||||
case "video-color": {
|
||||
return "Adjusted";
|
||||
}
|
||||
case "video-speed": {
|
||||
if (settings.factor) return `${settings.factor}x`;
|
||||
return "";
|
||||
}
|
||||
case "aspect-pad": {
|
||||
if (settings.target) return String(settings.target);
|
||||
return "";
|
||||
}
|
||||
case "blur-pad": {
|
||||
if (settings.target) return String(settings.target);
|
||||
return "";
|
||||
}
|
||||
case "watermark-video": {
|
||||
if (settings.text) {
|
||||
const txt = String(settings.text);
|
||||
return txt.length > 25 ? `${txt.slice(0, 24)}...` : txt;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
case "trim-audio": {
|
||||
if (settings.endS != null) return `${settings.startS ?? 0}-${settings.endS}s`;
|
||||
return "";
|
||||
}
|
||||
case "volume-adjust": {
|
||||
if (settings.gainDb != null) return `${settings.gainDb} dB`;
|
||||
return "";
|
||||
}
|
||||
case "audio-speed": {
|
||||
if (settings.factor != null) return `${settings.factor}x`;
|
||||
return "";
|
||||
}
|
||||
case "pitch-shift": {
|
||||
if (settings.semitones != null) return `${settings.semitones} st`;
|
||||
return "";
|
||||
}
|
||||
case "audio-channels": {
|
||||
if (settings.mode) return String(settings.mode);
|
||||
return "";
|
||||
}
|
||||
case "ringtone-maker": {
|
||||
if (settings.durationS != null) return `${settings.durationS}s`;
|
||||
return "";
|
||||
}
|
||||
case "audio-metadata": {
|
||||
if (settings.strip) return "Strip";
|
||||
return "";
|
||||
}
|
||||
case "rotate-pdf": {
|
||||
if (settings.angle != null) return `${settings.angle}°`;
|
||||
return "";
|
||||
}
|
||||
case "convert-document":
|
||||
case "convert-presentation":
|
||||
case "convert-spreadsheet":
|
||||
case "epub-convert": {
|
||||
if (settings.format) return String(settings.format).toUpperCase();
|
||||
return "";
|
||||
}
|
||||
case "extract-pages": {
|
||||
return String(settings.range || "");
|
||||
}
|
||||
case "remove-pages": {
|
||||
return String(settings.pages || "");
|
||||
}
|
||||
case "organize-pdf": {
|
||||
return String(settings.order || "");
|
||||
}
|
||||
case "nup-pdf": {
|
||||
if (settings.perSheet != null) return `${settings.perSheet}-up`;
|
||||
return "";
|
||||
}
|
||||
case "watermark-pdf": {
|
||||
if (settings.text) {
|
||||
const txt = String(settings.text);
|
||||
return txt.length > 25 ? `${txt.slice(0, 24)}...` : txt;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
case "redact-pdf": {
|
||||
const terms = settings.terms as string[] | undefined;
|
||||
if (terms && terms.length > 0) return `${terms.length} terms`;
|
||||
return "";
|
||||
}
|
||||
case "pdf-metadata": {
|
||||
if (settings.title || settings.author || settings.subject || settings.keywords)
|
||||
return "Metadata";
|
||||
return "";
|
||||
}
|
||||
case "compress-pdf": {
|
||||
if (settings.mode === "targetSize" && settings.targetSizeKb)
|
||||
return `Target ${settings.targetSizeKb} KB`;
|
||||
if (settings.quality != null) return `Quality ${settings.quality}`;
|
||||
return "";
|
||||
}
|
||||
case "chart-maker": {
|
||||
if (settings.kind) return String(settings.kind);
|
||||
return "";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -70,3 +70,49 @@ export function PitchShiftSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PitchShiftControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function PitchShiftControls({ settings: initial, onChange }: PitchShiftControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["pitch-shift"];
|
||||
const [semitones, setSemitones] = useState(3);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.semitones != null) setSemitones(Number(initial.semitones));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ semitones });
|
||||
}, [semitones]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="psp-semi" className="text-xs text-muted-foreground">
|
||||
{s.semitones}
|
||||
</label>
|
||||
<input
|
||||
id="psp-semi"
|
||||
type="number"
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
value={semitones}
|
||||
onChange={(e) => setSemitones(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -86,3 +86,64 @@ export function RedactPdfSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RedactPdfControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function RedactPdfControls({ settings: initial, onChange }: RedactPdfControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["redact-pdf"];
|
||||
const [termsText, setTermsText] = useState("");
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (Array.isArray(initial.terms)) setTermsText((initial.terms as string[]).join(", "));
|
||||
if (initial.caseSensitive != null) setCaseSensitive(Boolean(initial.caseSensitive));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
const terms = termsText
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
onChangeRef.current?.({ terms, caseSensitive });
|
||||
}, [termsText, caseSensitive]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rdc-terms" className="text-xs text-muted-foreground">
|
||||
{s.terms}
|
||||
</label>
|
||||
<input
|
||||
id="rdc-terms"
|
||||
type="text"
|
||||
value={termsText}
|
||||
onChange={(e) => setTermsText(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 className="flex items-center gap-2">
|
||||
<input
|
||||
id="rdc-case"
|
||||
type="checkbox"
|
||||
checked={caseSensitive}
|
||||
onChange={(e) => setCaseSensitive(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="rdc-case" className="text-xs text-muted-foreground">
|
||||
{s.caseSensitive}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -67,3 +67,47 @@ export function RemovePagesSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RemovePagesControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function RemovePagesControls({ settings: initial, onChange }: RemovePagesControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["remove-pages"];
|
||||
const [pages, setPages] = useState("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.pages != null) setPages(String(initial.pages));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ pages });
|
||||
}, [pages]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rpc-pages" className="text-xs text-muted-foreground">
|
||||
{s.pages}
|
||||
</label>
|
||||
<input
|
||||
id="rpc-pages"
|
||||
type="text"
|
||||
value={pages}
|
||||
onChange={(e) => setPages(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.pagesHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -119,3 +119,96 @@ export function ResizeVideoSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResizeVideoControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ResizeVideoControls({ settings: initial, onChange }: ResizeVideoControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["resize-video"];
|
||||
const [preset, setPreset] = useState<Preset>("custom");
|
||||
const [width, setWidth] = useState<number | "">("");
|
||||
const [height, setHeight] = useState<number | "">("");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.preset != null) setPreset(initial.preset as Preset);
|
||||
if (initial.width != null) setWidth(Number(initial.width));
|
||||
if (initial.height != null) setHeight(Number(initial.height));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
const out: Record<string, unknown> = { preset };
|
||||
if (preset === "custom") {
|
||||
if (width !== "") out.width = width;
|
||||
if (height !== "") out.height = height;
|
||||
}
|
||||
onChangeRef.current?.(out);
|
||||
}, [preset, width, height]);
|
||||
|
||||
const isCustom = preset === "custom";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rvp-preset" className="text-xs text-muted-foreground">
|
||||
{s.preset}
|
||||
</label>
|
||||
<select
|
||||
id="rvp-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="rvp-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="rvp-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="rvp-height" className="text-xs text-muted-foreground">
|
||||
{s.height}
|
||||
</label>
|
||||
<input
|
||||
id="rvp-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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -86,3 +86,65 @@ export function RingtoneMakerSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RingtoneMakerControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function RingtoneMakerControls({ settings: initial, onChange }: RingtoneMakerControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["ringtone-maker"];
|
||||
const [startS, setStartS] = useState(0);
|
||||
const [durationS, setDurationS] = useState(30);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.startS != null) setStartS(Number(initial.startS));
|
||||
if (initial.durationS != null) setDurationS(Number(initial.durationS));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ startS, durationS });
|
||||
}, [startS, durationS]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rmp-start" className="text-xs text-muted-foreground">
|
||||
{s["start-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="rmp-start"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={startS}
|
||||
onChange={(e) => setStartS(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="rmp-duration" className="text-xs text-muted-foreground">
|
||||
{s["duration-s"]}
|
||||
</label>
|
||||
<input
|
||||
id="rmp-duration"
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
step={0.5}
|
||||
value={durationS}
|
||||
onChange={(e) => setDurationS(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -86,3 +86,64 @@ export function RotatePdfSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RotatePdfControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function RotatePdfControls({ settings: initial, onChange }: RotatePdfControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["rotate-pdf"];
|
||||
const [angle, setAngle] = useState<Angle>(90);
|
||||
const [range, setRange] = useState("1-z");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.angle != null) setAngle(Number(initial.angle) as Angle);
|
||||
if (initial.range != null) setRange(String(initial.range));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ angle, range });
|
||||
}, [angle, range]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rpdc-angle" className="text-xs text-muted-foreground">
|
||||
{s.angle}
|
||||
</label>
|
||||
<select
|
||||
id="rpdc-angle"
|
||||
value={angle}
|
||||
onChange={(e) => setAngle(Number(e.target.value) as Angle)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value={90}>90</option>
|
||||
<option value={180}>180</option>
|
||||
<option value={270}>270</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rpdc-range" className="text-xs text-muted-foreground">
|
||||
{s.range}
|
||||
</label>
|
||||
<input
|
||||
id="rpdc-range"
|
||||
type="text"
|
||||
value={range}
|
||||
onChange={(e) => setRange(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.rangeHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -73,3 +73,51 @@ export function RotateVideoSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RotateVideoControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function RotateVideoControls({ settings: initial, onChange }: RotateVideoControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["rotate-video"];
|
||||
const [transform, setTransform] = useState<Transform>("cw90");
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.transform != null) setTransform(initial.transform as Transform);
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ transform });
|
||||
}, [transform]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="rtvp-transform" className="text-xs text-muted-foreground">
|
||||
{s.transform}
|
||||
</label>
|
||||
<select
|
||||
id="rtvp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -88,3 +88,64 @@ export function TrimAudioSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrimAudioControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function TrimAudioControls({ settings: initial, onChange }: TrimAudioControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["trim-audio"];
|
||||
const [startS, setStartS] = useState(0);
|
||||
const [endS, setEndS] = useState(0);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.startS != null) setStartS(Number(initial.startS));
|
||||
if (initial.endS != null) setEndS(Number(initial.endS));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ startS, endS });
|
||||
}, [startS, endS]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="tap-start" className="text-xs text-muted-foreground">
|
||||
{s.start}
|
||||
</label>
|
||||
<input
|
||||
id="tap-start"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={startS}
|
||||
onChange={(e) => setStartS(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="tap-end" className="text-xs text-muted-foreground">
|
||||
{s.end}
|
||||
</label>
|
||||
<input
|
||||
id="tap-end"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={endS}
|
||||
onChange={(e) => setEndS(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -99,3 +99,75 @@ export function TrimVideoSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrimVideoControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function TrimVideoControls({ settings: initial, onChange }: TrimVideoControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["trim-video"];
|
||||
const [startS, setStartS] = useState(0);
|
||||
const [endS, setEndS] = useState(0);
|
||||
const [precise, setPrecise] = useState(false);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.startS != null) setStartS(Number(initial.startS));
|
||||
if (initial.endS != null) setEndS(Number(initial.endS));
|
||||
if (initial.precise != null) setPrecise(Boolean(initial.precise));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ startS, endS, precise });
|
||||
}, [startS, endS, precise]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="tvp-start" className="text-xs text-muted-foreground">
|
||||
{s.start}
|
||||
</label>
|
||||
<input
|
||||
id="tvp-start"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={startS}
|
||||
onChange={(e) => setStartS(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="tvp-end" className="text-xs text-muted-foreground">
|
||||
{s.end}
|
||||
</label>
|
||||
<input
|
||||
id="tvp-end"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={endS}
|
||||
onChange={(e) => setEndS(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={precise}
|
||||
onChange={(e) => setPrecise(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{s.precise}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -120,3 +120,100 @@ export function VideoColorSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VideoColorControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function VideoColorControls({ settings: initial, onChange }: VideoColorControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-color"];
|
||||
const [brightness, setBrightness] = useState(0);
|
||||
const [contrast, setContrast] = useState(1);
|
||||
const [saturation, setSaturation] = useState(1);
|
||||
const [gamma, setGamma] = useState(1);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.brightness != null) setBrightness(Number(initial.brightness));
|
||||
if (initial.contrast != null) setContrast(Number(initial.contrast));
|
||||
if (initial.saturation != null) setSaturation(Number(initial.saturation));
|
||||
if (initial.gamma != null) setGamma(Number(initial.gamma));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ brightness, contrast, saturation, gamma });
|
||||
}, [brightness, contrast, saturation, gamma]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="vcp-brightness" className="text-xs text-muted-foreground">
|
||||
{s.brightness}
|
||||
</label>
|
||||
<input
|
||||
id="vcp-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="vcp-contrast" className="text-xs text-muted-foreground">
|
||||
{s.contrast}
|
||||
</label>
|
||||
<input
|
||||
id="vcp-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="vcp-saturation" className="text-xs text-muted-foreground">
|
||||
{s.saturation}
|
||||
</label>
|
||||
<input
|
||||
id="vcp-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="vcp-gamma" className="text-xs text-muted-foreground">
|
||||
{s.gamma}
|
||||
</label>
|
||||
<input
|
||||
id="vcp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -80,3 +80,60 @@ export function VideoSpeedSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VideoSpeedControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function VideoSpeedControls({ settings: initial, onChange }: VideoSpeedControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-speed"];
|
||||
const [factor, setFactor] = useState(2);
|
||||
const [keepPitch, setKeepPitch] = useState(true);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.factor != null) setFactor(Number(initial.factor));
|
||||
if (initial.keepPitch != null) setKeepPitch(Boolean(initial.keepPitch));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ factor, keepPitch });
|
||||
}, [factor, keepPitch]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="vsp-factor" className="text-xs text-muted-foreground">
|
||||
{s.factor}
|
||||
</label>
|
||||
<input
|
||||
id="vsp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -119,3 +119,99 @@ export function VideoToGifSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VideoToGifControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function VideoToGifControls({ settings: initial, onChange }: VideoToGifControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-to-gif"];
|
||||
const [fps, setFps] = useState(12);
|
||||
const [width, setWidth] = useState(480);
|
||||
const [startS, setStartS] = useState(0);
|
||||
const [durationS, setDurationS] = useState(5);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.fps != null) setFps(Number(initial.fps));
|
||||
if (initial.width != null) setWidth(Number(initial.width));
|
||||
if (initial.startS != null) setStartS(Number(initial.startS));
|
||||
if (initial.durationS != null) setDurationS(Number(initial.durationS));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ fps, width, startS, durationS });
|
||||
}, [fps, width, startS, durationS]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="vtgp-fps" className="text-xs text-muted-foreground">
|
||||
{s.fps}
|
||||
</label>
|
||||
<input
|
||||
id="vtgp-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="vtgp-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="vtgp-width"
|
||||
type="number"
|
||||
min={64}
|
||||
max={1280}
|
||||
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="vtgp-start" className="text-xs text-muted-foreground">
|
||||
{s.start}
|
||||
</label>
|
||||
<input
|
||||
id="vtgp-start"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={startS}
|
||||
onChange={(e) => setStartS(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="vtgp-duration" className="text-xs text-muted-foreground">
|
||||
{s.duration}
|
||||
</label>
|
||||
<input
|
||||
id="vtgp-duration"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={60}
|
||||
step={0.1}
|
||||
value={durationS}
|
||||
onChange={(e) => setDurationS(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -114,3 +114,94 @@ export function VideoToWebpSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VideoToWebpControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function VideoToWebpControls({ settings: initial, onChange }: VideoToWebpControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["video-to-webp"];
|
||||
const [fps, setFps] = useState(12);
|
||||
const [width, setWidth] = useState(480);
|
||||
const [quality, setQuality] = useState(75);
|
||||
const [loop, setLoop] = useState(true);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.fps != null) setFps(Number(initial.fps));
|
||||
if (initial.width != null) setWidth(Number(initial.width));
|
||||
if (initial.quality != null) setQuality(Number(initial.quality));
|
||||
if (initial.loop != null) setLoop(Boolean(initial.loop));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ fps, width, quality, loop });
|
||||
}, [fps, width, quality, loop]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="v2wp-fps" className="text-xs text-muted-foreground">
|
||||
{s.fps}
|
||||
</label>
|
||||
<input
|
||||
id="v2wp-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="v2wp-width" className="text-xs text-muted-foreground">
|
||||
{s.width}
|
||||
</label>
|
||||
<input
|
||||
id="v2wp-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="v2wp-quality" className="text-xs text-muted-foreground">
|
||||
{s.quality}
|
||||
</label>
|
||||
<input
|
||||
id="v2wp-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -69,3 +69,49 @@ export function VolumeAdjustSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface VolumeAdjustControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function VolumeAdjustControls({ settings: initial, onChange }: VolumeAdjustControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["volume-adjust"];
|
||||
const [gainDb, setGainDb] = useState(3);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.gainDb != null) setGainDb(Number(initial.gainDb));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ gainDb });
|
||||
}, [gainDb]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="vap-gain" className="text-xs text-muted-foreground">
|
||||
{s["gain-db"]}
|
||||
</label>
|
||||
<input
|
||||
id="vap-gain"
|
||||
type="number"
|
||||
min={-30}
|
||||
max={30}
|
||||
step={0.5}
|
||||
value={gainDb}
|
||||
onChange={(e) => setGainDb(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -143,3 +143,121 @@ export function WatermarkPdfSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WatermarkPdfControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function WatermarkPdfControls({ settings: initial, onChange }: WatermarkPdfControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["watermark-pdf"];
|
||||
const [text, setText] = useState("");
|
||||
const [position, setPosition] = useState<Position>("c");
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
const [opacity, setOpacity] = useState(0.3);
|
||||
const [rotation, setRotation] = useState(45);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.text != null) setText(String(initial.text));
|
||||
if (initial.position != null) setPosition(initial.position as Position);
|
||||
if (initial.fontSize != null) setFontSize(Number(initial.fontSize));
|
||||
if (initial.opacity != null) setOpacity(Number(initial.opacity));
|
||||
if (initial.rotation != null) setRotation(Number(initial.rotation));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ text, position, fontSize, opacity, rotation });
|
||||
}, [text, position, fontSize, opacity, rotation]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="wmc-text" className="text-xs text-muted-foreground">
|
||||
{s.text}
|
||||
</label>
|
||||
<input
|
||||
id="wmc-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="wmc-position" className="text-xs text-muted-foreground">
|
||||
{s.position}
|
||||
</label>
|
||||
<select
|
||||
id="wmc-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="wmc-font-size" className="text-xs text-muted-foreground">
|
||||
{s.fontSize}
|
||||
</label>
|
||||
<input
|
||||
id="wmc-font-size"
|
||||
type="number"
|
||||
min={6}
|
||||
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>
|
||||
<div>
|
||||
<label htmlFor="wmc-opacity" className="text-xs text-muted-foreground">
|
||||
{s.opacity}
|
||||
</label>
|
||||
<input
|
||||
id="wmc-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="wmc-rotation" className="text-xs text-muted-foreground">
|
||||
{s.rotation}
|
||||
</label>
|
||||
<input
|
||||
id="wmc-rotation"
|
||||
type="number"
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -143,3 +143,121 @@ export function WatermarkVideoSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WatermarkVideoControlsProps {
|
||||
settings?: Record<string, unknown>;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function WatermarkVideoControls({
|
||||
settings: initial,
|
||||
onChange,
|
||||
}: WatermarkVideoControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["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 initializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initial || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
if (initial.text != null) setText(String(initial.text));
|
||||
if (initial.position != null) setPosition(initial.position as Position);
|
||||
if (initial.fontSize != null) setFontSize(Number(initial.fontSize));
|
||||
if (initial.opacity != null) setOpacity(Number(initial.opacity));
|
||||
if (initial.color != null) setColor(String(initial.color));
|
||||
}, [initial]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ text, position, fontSize, opacity, color });
|
||||
}, [text, position, fontSize, opacity, color]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="wmvp-text" className="text-xs text-muted-foreground">
|
||||
{s.text}
|
||||
</label>
|
||||
<input
|
||||
id="wmvp-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="wmvp-position" className="text-xs text-muted-foreground">
|
||||
{s.position}
|
||||
</label>
|
||||
<select
|
||||
id="wmvp-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="wmvp-font-size" className="text-xs text-muted-foreground">
|
||||
{s.fontSize}
|
||||
</label>
|
||||
<input
|
||||
id="wmvp-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="wmvp-opacity" className="text-xs text-muted-foreground">
|
||||
{s.opacity}
|
||||
</label>
|
||||
<input
|
||||
id="wmvp-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="wmvp-color" className="text-xs text-muted-foreground">
|
||||
{s.color}
|
||||
</label>
|
||||
<input
|
||||
id="wmvp-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type Modality, TOOLS, toolInputModality, toolOutputModality } from "@snapotter/shared";
|
||||
|
||||
export interface StepWarning {
|
||||
expects: Modality;
|
||||
receives: Modality;
|
||||
}
|
||||
|
||||
/** For each step, a warning if its expected input modality does not match its source
|
||||
* (previous step's output, or the uploaded file for step 0). null = compatible / unknown. */
|
||||
export function computeStepWarnings(
|
||||
toolIds: string[],
|
||||
uploadedModality: Modality | null,
|
||||
): (StepWarning | null)[] {
|
||||
return toolIds.map((id, i) => {
|
||||
const tool = TOOLS.find((t) => t.id === id);
|
||||
if (!tool) return null;
|
||||
const expects = toolInputModality(tool);
|
||||
let receives: Modality | null;
|
||||
if (i === 0) {
|
||||
receives = uploadedModality;
|
||||
} else {
|
||||
const prev = TOOLS.find((t) => t.id === toolIds[i - 1]);
|
||||
receives = prev ? toolOutputModality(prev) : null;
|
||||
}
|
||||
return receives && receives !== expects ? { expects, receives } : null;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { modalityForExtension } from "@snapotter/shared";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
@@ -5,8 +6,12 @@ import {
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
FileImage,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FolderOpen,
|
||||
Image as ImageIcon,
|
||||
Layers,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -45,6 +50,21 @@ const WaveformPlayer = lazy(() =>
|
||||
import("@/components/common/waveform-player").then((m) => ({ default: m.WaveformPlayer })),
|
||||
);
|
||||
|
||||
function previewIcon(kind: string) {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return ImageIcon;
|
||||
case "video":
|
||||
return FileVideo;
|
||||
case "audio":
|
||||
return FileAudio;
|
||||
case "document":
|
||||
return FileText;
|
||||
default:
|
||||
return FileArchive;
|
||||
}
|
||||
}
|
||||
|
||||
export function AutomatePage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t.sidebar.automate);
|
||||
@@ -104,6 +124,12 @@ export function AutomatePage() {
|
||||
const hasPrev = selectedIndex > 0;
|
||||
const hasNext = selectedIndex < entries.length - 1;
|
||||
|
||||
const firstEntry = entries[0];
|
||||
const uploadedModality = firstEntry
|
||||
? (modalityForExtension(firstEntry.file.name.slice(firstEntry.file.name.lastIndexOf("."))) ??
|
||||
firstEntry.modality)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -347,7 +373,15 @@ export function AutomatePage() {
|
||||
URL.revokeObjectURL(url);
|
||||
}, [batchZipBlob, batchZipFilename]);
|
||||
|
||||
const handleImageKeyDown = useCallback(
|
||||
const handleDownloadSingle = useCallback(() => {
|
||||
if (!processedUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = processedUrl;
|
||||
a.download = currentEntry?.processedFilename ?? "result";
|
||||
a.click();
|
||||
}, [processedUrl, currentEntry]);
|
||||
|
||||
const handleNavKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
@@ -453,7 +487,10 @@ export function AutomatePage() {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center p-6 max-w-xs">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-muted flex items-center justify-center mb-3">
|
||||
<FileImage className="h-7 w-7 text-muted-foreground" />
|
||||
{(() => {
|
||||
const Icon = previewIcon(kind);
|
||||
return <Icon className="h-7 w-7 text-muted-foreground" />;
|
||||
})()}
|
||||
</div>
|
||||
<p className="font-medium text-foreground mb-1">{fname}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -537,7 +574,7 @@ export function AutomatePage() {
|
||||
{hasFile && hasProcessed && originalBlobUrl && (
|
||||
<div className="mb-3 rounded-lg border border-border overflow-hidden">
|
||||
<div className="relative h-48">{renderPipelinePreview("result")}</div>
|
||||
{processedSize != null && currentEntry?.previewKind === "image" && (
|
||||
{processedSize != null && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground">
|
||||
<span className="truncate">{selectedFileName ?? files[0].name}</span>
|
||||
<span>
|
||||
@@ -575,6 +612,7 @@ export function AutomatePage() {
|
||||
<PipelineBuilder
|
||||
steps={steps}
|
||||
expandedStepId={expandedStepId}
|
||||
uploadedModality={uploadedModality}
|
||||
onRemoveStep={removeStep}
|
||||
onReorderSteps={reorderSteps}
|
||||
onUpdateSettings={updateStepSettings}
|
||||
@@ -626,6 +664,15 @@ export function AutomatePage() {
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{hasProcessed && !batchZipBlob && processedUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadSingle}
|
||||
className="px-4 py-2.5 rounded-lg border border-primary text-primary"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for tool palette */}
|
||||
@@ -877,6 +924,7 @@ export function AutomatePage() {
|
||||
<PipelineBuilder
|
||||
steps={steps}
|
||||
expandedStepId={expandedStepId}
|
||||
uploadedModality={uploadedModality}
|
||||
onRemoveStep={removeStep}
|
||||
onReorderSteps={reorderSteps}
|
||||
onUpdateSettings={updateStepSettings}
|
||||
@@ -925,6 +973,16 @@ export function AutomatePage() {
|
||||
{t.automate.downloadZip}
|
||||
</button>
|
||||
)}
|
||||
{hasProcessed && !batchZipBlob && processedUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadSingle}
|
||||
className="px-4 py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
@@ -1011,7 +1069,7 @@ export function AutomatePage() {
|
||||
<section
|
||||
aria-label={t.a11y.imageArea}
|
||||
className="flex-1 flex flex-col overflow-hidden min-h-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
onKeyDown={hasMultiple ? handleNavKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-1 relative flex items-center justify-center px-6 py-2 min-h-0">
|
||||
|
||||
@@ -146,6 +146,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/image-to-pdf",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
outputModality: "document",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -247,6 +248,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/ocr",
|
||||
modality: "image",
|
||||
acceptedInputs: IMAGE_INPUTS,
|
||||
outputModality: "file",
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
@@ -258,6 +260,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/ocr-pdf",
|
||||
modality: "document",
|
||||
acceptedInputs: [".pdf"],
|
||||
outputModality: "file",
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
@@ -390,6 +393,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/transcribe-audio",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
outputModality: "file",
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
@@ -401,6 +405,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/auto-subtitles",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
outputModality: "file",
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
@@ -791,6 +796,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/pdf-to-image",
|
||||
modality: "document",
|
||||
acceptedInputs: [".pdf"],
|
||||
outputModality: "image",
|
||||
executionHint: "fast",
|
||||
},
|
||||
// Video
|
||||
@@ -847,6 +853,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/video-to-gif",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
outputModality: "image",
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
@@ -1001,6 +1008,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/video-to-webp",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
outputModality: "image",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -1012,6 +1020,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/video-to-frames",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
outputModality: "file",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -1067,6 +1076,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/extract-subtitles",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
outputModality: "file",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -1123,6 +1133,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/extract-audio",
|
||||
modality: "video",
|
||||
acceptedInputs: VIDEO_INPUTS,
|
||||
outputModality: "audio",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -1266,6 +1277,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/waveform-image",
|
||||
modality: "audio",
|
||||
acceptedInputs: AUDIO_INPUTS,
|
||||
outputModality: "image",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -1565,6 +1577,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/pdf-to-text",
|
||||
modality: "document",
|
||||
acceptedInputs: [".pdf"],
|
||||
outputModality: "file",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
@@ -1679,6 +1692,7 @@ export const TOOLS: Tool[] = [
|
||||
route: "/chart-maker",
|
||||
modality: "file",
|
||||
acceptedInputs: [".csv", ".json"],
|
||||
outputModality: "image",
|
||||
executionHint: "fast",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3199,6 +3199,9 @@ export const ar: TranslationKeys = {
|
||||
noStepsHeading: "لا توجد خطوات بعد",
|
||||
searchToolsPlaceholder: "بحث في الأدوات...",
|
||||
step: "خطوة",
|
||||
modalityWarning: "عدم توافق النوع",
|
||||
modalityWarningTooltip:
|
||||
"تتوقع هذه الخطوة {expected} لكنها تتلقى {received}؛ قد تفشل عند تشغيل المسار.",
|
||||
},
|
||||
nav: {
|
||||
tools: "الأدوات",
|
||||
|
||||
@@ -3238,6 +3238,9 @@ export const de: TranslationKeys = {
|
||||
noStepsHeading: "Noch keine Schritte",
|
||||
searchToolsPlaceholder: "Werkzeuge suchen...",
|
||||
step: "Schritt",
|
||||
modalityWarning: "Modalitätskonflikt",
|
||||
modalityWarningTooltip:
|
||||
"Dieser Schritt erwartet {expected}, erhält aber {received}; er kann beim Ausführen der Pipeline fehlschlagen.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Werkzeuge",
|
||||
|
||||
@@ -3168,6 +3168,9 @@ export const en = {
|
||||
noStepsHeading: "No steps yet",
|
||||
searchToolsPlaceholder: "Search tools...",
|
||||
step: "Step",
|
||||
modalityWarning: "Modality mismatch",
|
||||
modalityWarningTooltip:
|
||||
"This step expects {expected} but receives {received}; it may fail when you run the pipeline.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Tools",
|
||||
|
||||
@@ -3220,6 +3220,9 @@ export const es: TranslationKeys = {
|
||||
noStepsHeading: "Aún no hay pasos",
|
||||
searchToolsPlaceholder: "Buscar herramientas...",
|
||||
step: "Paso",
|
||||
modalityWarning: "Incompatibilidad de modalidad",
|
||||
modalityWarningTooltip:
|
||||
"Este paso espera {expected} pero recibe {received}; puede fallar al ejecutar la canalización.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Herramientas",
|
||||
|
||||
@@ -3243,6 +3243,9 @@ export const fr: TranslationKeys = {
|
||||
noStepsHeading: "Aucune étape pour le moment",
|
||||
searchToolsPlaceholder: "Rechercher des outils...",
|
||||
step: "Étape",
|
||||
modalityWarning: "Incompatibilité de modalité",
|
||||
modalityWarningTooltip:
|
||||
"Cette étape attend {expected} mais reçoit {received} ; elle peut échouer lors de l'exécution du pipeline.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Outils",
|
||||
|
||||
@@ -3194,6 +3194,9 @@ export const hi: TranslationKeys = {
|
||||
noStepsHeading: "अभी तक कोई चरण नहीं",
|
||||
searchToolsPlaceholder: "टूल खोजें...",
|
||||
step: "स्टेप",
|
||||
modalityWarning: "मोडैलिटी मेल नहीं खाती",
|
||||
modalityWarningTooltip:
|
||||
"यह चरण {expected} की अपेक्षा करता है लेकिन {received} प्राप्त करता है; पाइपलाइन चलाने पर यह विफल हो सकता है।",
|
||||
},
|
||||
nav: {
|
||||
tools: "टूल्स",
|
||||
|
||||
@@ -3220,6 +3220,9 @@ export const id: TranslationKeys = {
|
||||
noStepsHeading: "Belum ada langkah",
|
||||
searchToolsPlaceholder: "Cari alat...",
|
||||
step: "Langkah",
|
||||
modalityWarning: "Ketidakcocokan modalitas",
|
||||
modalityWarningTooltip:
|
||||
"Langkah ini mengharapkan {expected} tetapi menerima {received}; mungkin gagal saat Anda menjalankan pipeline.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Alat",
|
||||
|
||||
@@ -3232,6 +3232,9 @@ export const it: TranslationKeys = {
|
||||
noStepsHeading: "Nessun passaggio",
|
||||
searchToolsPlaceholder: "Cerca strumenti...",
|
||||
step: "Passaggio",
|
||||
modalityWarning: "Incompatibilità di modalità",
|
||||
modalityWarningTooltip:
|
||||
"Questo passaggio prevede {expected} ma riceve {received}; potrebbe non riuscire durante l'esecuzione della pipeline.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Strumenti",
|
||||
|
||||
@@ -3171,6 +3171,9 @@ export const ja: TranslationKeys = {
|
||||
noStepsHeading: "ステップがありません",
|
||||
searchToolsPlaceholder: "ツールを検索...",
|
||||
step: "ステップ",
|
||||
modalityWarning: "モダリティ不一致",
|
||||
modalityWarningTooltip:
|
||||
"このステップは {expected} を想定していますが {received} を受け取ります。パイプラインの実行時に失敗する可能性があります。",
|
||||
},
|
||||
nav: {
|
||||
tools: "ツール",
|
||||
|
||||
@@ -3154,6 +3154,9 @@ export const ko: TranslationKeys = {
|
||||
noStepsHeading: "단계가 없습니다",
|
||||
searchToolsPlaceholder: "도구 검색...",
|
||||
step: "단계",
|
||||
modalityWarning: "모달리티 불일치",
|
||||
modalityWarningTooltip:
|
||||
"이 단계는 {expected}을(를) 기대하지만 {received}을(를) 받습니다. 파이프라인을 실행하면 실패할 수 있습니다.",
|
||||
},
|
||||
nav: {
|
||||
tools: "도구",
|
||||
|
||||
@@ -3228,6 +3228,9 @@ export const nl: TranslationKeys = {
|
||||
noStepsHeading: "Nog geen stappen",
|
||||
searchToolsPlaceholder: "Tools zoeken...",
|
||||
step: "Stap",
|
||||
modalityWarning: "Modaliteitsmismatch",
|
||||
modalityWarningTooltip:
|
||||
"Deze stap verwacht {expected} maar ontvangt {received}; mogelijk mislukt deze bij het uitvoeren van de pipeline.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Tools",
|
||||
|
||||
@@ -3235,6 +3235,9 @@ export const pl: TranslationKeys = {
|
||||
noStepsHeading: "Brak kroków",
|
||||
searchToolsPlaceholder: "Szukaj narzędzi...",
|
||||
step: "Krok",
|
||||
modalityWarning: "Niezgodność modalności",
|
||||
modalityWarningTooltip:
|
||||
"Ten krok oczekuje {expected}, ale otrzymuje {received}; może się nie powieść podczas uruchamiania potoku.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Narzędzia",
|
||||
|
||||
@@ -3227,6 +3227,9 @@ export const ptBR: TranslationKeys = {
|
||||
noStepsHeading: "Nenhuma etapa ainda",
|
||||
searchToolsPlaceholder: "Buscar ferramentas...",
|
||||
step: "Passo",
|
||||
modalityWarning: "Incompatibilidade de modalidade",
|
||||
modalityWarningTooltip:
|
||||
"Esta etapa espera {expected}, mas recebe {received}; pode falhar ao executar o pipeline.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Ferramentas",
|
||||
|
||||
@@ -3223,6 +3223,9 @@ export const ru: TranslationKeys = {
|
||||
noStepsHeading: "Шагов пока нет",
|
||||
searchToolsPlaceholder: "Поиск инструментов...",
|
||||
step: "Шаг",
|
||||
modalityWarning: "Несоответствие модальности",
|
||||
modalityWarningTooltip:
|
||||
"Этот шаг ожидает {expected}, но получает {received}; он может завершиться ошибкой при запуске конвейера.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Инструменты",
|
||||
|
||||
@@ -3216,6 +3216,9 @@ export const sv: TranslationKeys = {
|
||||
noStepsHeading: "Inga steg ännu",
|
||||
searchToolsPlaceholder: "Sök verktyg...",
|
||||
step: "Steg",
|
||||
modalityWarning: "Modalitetsfel",
|
||||
modalityWarningTooltip:
|
||||
"Det här steget förväntar sig {expected} men tar emot {received}; det kan misslyckas när du kör pipelinen.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Verktyg",
|
||||
|
||||
@@ -3180,6 +3180,8 @@ export const th: TranslationKeys = {
|
||||
noStepsHeading: "ยังไม่มีขั้นตอน",
|
||||
searchToolsPlaceholder: "ค้นหาเครื่องมือ...",
|
||||
step: "ขั้นตอน",
|
||||
modalityWarning: "ประเภทไม่ตรงกัน",
|
||||
modalityWarningTooltip: "ขั้นตอนนี้คาดหวัง {expected} แต่ได้รับ {received} อาจล้มเหลวเมื่อคุณเรียกใช้ไปป์ไลน์",
|
||||
},
|
||||
nav: {
|
||||
tools: "เครื่องมือ",
|
||||
|
||||
@@ -3229,6 +3229,9 @@ export const tr: TranslationKeys = {
|
||||
noStepsHeading: "Henüz adım yok",
|
||||
searchToolsPlaceholder: "Araç ara...",
|
||||
step: "Adım",
|
||||
modalityWarning: "Modalite uyumsuzluğu",
|
||||
modalityWarningTooltip:
|
||||
"Bu adım {expected} bekliyor ancak {received} alıyor; işlem hattını çalıştırdığınızda başarısız olabilir.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Araçlar",
|
||||
|
||||
@@ -3225,6 +3225,9 @@ export const uk: TranslationKeys = {
|
||||
noStepsHeading: "Кроків поки немає",
|
||||
searchToolsPlaceholder: "Пошук інструментів...",
|
||||
step: "Крок",
|
||||
modalityWarning: "Невідповідність модальності",
|
||||
modalityWarningTooltip:
|
||||
"Цей крок очікує {expected}, але отримує {received}; він може завершитися помилкою під час запуску конвеєра.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Інструменти",
|
||||
|
||||
@@ -3217,6 +3217,9 @@ export const vi: TranslationKeys = {
|
||||
noStepsHeading: "Chưa có bước nào",
|
||||
searchToolsPlaceholder: "Tìm công cụ...",
|
||||
step: "Bước",
|
||||
modalityWarning: "Không tương thích loại",
|
||||
modalityWarningTooltip:
|
||||
"Bước này cần {expected} nhưng nhận {received}; có thể không thành công khi bạn chạy quy trình.",
|
||||
},
|
||||
nav: {
|
||||
tools: "Công cụ",
|
||||
|
||||
@@ -3130,6 +3130,8 @@ export const zhCN: TranslationKeys = {
|
||||
noStepsHeading: "暂无步骤",
|
||||
searchToolsPlaceholder: "搜索工具...",
|
||||
step: "步骤",
|
||||
modalityWarning: "模态不匹配",
|
||||
modalityWarningTooltip: "此步骤需要 {expected},但接收到 {received};运行管道时可能会失败。",
|
||||
},
|
||||
nav: {
|
||||
tools: "工具",
|
||||
|
||||
@@ -3129,6 +3129,8 @@ export const zhTW: TranslationKeys = {
|
||||
noStepsHeading: "尚無步驟",
|
||||
searchToolsPlaceholder: "搜尋工具...",
|
||||
step: "步驟",
|
||||
modalityWarning: "模態不相符",
|
||||
modalityWarningTooltip: "此步驟需要 {expected},但收到 {received};執行管線時可能會失敗。",
|
||||
},
|
||||
nav: {
|
||||
tools: "工具",
|
||||
|
||||
@@ -134,3 +134,42 @@ export function detectModalityFromMime(mime: string): Modality {
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
const MODALITY_EXTENSION_SETS: [Modality, readonly string[]][] = [
|
||||
["image", IMAGE_INPUTS],
|
||||
["video", VIDEO_INPUTS],
|
||||
["audio", AUDIO_INPUTS],
|
||||
["document", DOCUMENT_INPUTS],
|
||||
["file", FILE_INPUTS],
|
||||
];
|
||||
|
||||
/** Owning modality for a file extension (with or without leading dot), or null. */
|
||||
export function modalityForExtension(ext: string): Modality | null {
|
||||
const norm = (ext.startsWith(".") ? ext : `.${ext}`).toLowerCase();
|
||||
for (const [modality, set] of MODALITY_EXTENSION_SETS) {
|
||||
if (set.includes(norm)) return modality;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The modality a tool consumes. Derived from acceptedInputs so tools whose `modality`
|
||||
* differs from their real input (gif-to-video, images-to-video) are correct. */
|
||||
export function toolInputModality(tool: {
|
||||
modality: Modality;
|
||||
acceptedInputs: string[];
|
||||
}): Modality {
|
||||
const first = tool.acceptedInputs[0];
|
||||
if (first) {
|
||||
const m = modalityForExtension(first);
|
||||
if (m) return m;
|
||||
}
|
||||
return tool.modality;
|
||||
}
|
||||
|
||||
/** The modality a tool produces. Uses the explicit override, else the tool modality. */
|
||||
export function toolOutputModality(tool: {
|
||||
modality: Modality;
|
||||
outputModality?: Modality;
|
||||
}): Modality {
|
||||
return tool.outputModality ?? tool.modality;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Tool {
|
||||
route: string;
|
||||
modality: Modality;
|
||||
acceptedInputs: string[];
|
||||
/** Modality of this tool's output, when it differs from `modality`. Defaults to `modality`. */
|
||||
outputModality?: Modality;
|
||||
executionHint: "fast" | "long";
|
||||
maxInputSizeMB?: number;
|
||||
shortcut?: string;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Pipeline multimodal pool-routing integration tests.
|
||||
*
|
||||
* Verifies that pipeline finalize and parent jobs are routed to the correct
|
||||
* BullMQ pool based on the first step's modality, rather than always
|
||||
* hardcoding "image".
|
||||
*/
|
||||
|
||||
import { gsAvailable, qpdfAvailable } from "@snapotter/doc-engine";
|
||||
import { ffmpegAvailable } from "@snapotter/media-engine";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../../../apps/api/src/db/index.js";
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../test-server.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
const PDF_FIXTURE = readFixture(fixtures.document.pdf2);
|
||||
const AUDIO_FIXTURE = readFixture(fixtures.audio.tiny("mp3"));
|
||||
const VIDEO_FIXTURE = readFixture(fixtures.video.tiny("mp4"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
// ---------------------------------------------------------------------------
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
/** Helper to POST a pipeline execution request. */
|
||||
function executePipeline(
|
||||
appInstance: TestApp["app"],
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
steps: Array<{ toolId: string; settings?: Record<string, unknown> }>,
|
||||
) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename, content: fileBuffer, contentType: "application/octet-stream" },
|
||||
{ name: "pipeline", content: JSON.stringify({ steps }) },
|
||||
]);
|
||||
return appInstance.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/pipeline/execute",
|
||||
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pool routing
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Pipeline multimodal pool routing", () => {
|
||||
it("routes a document pipeline's parent job to the docs pool", async () => {
|
||||
const res = await executePipeline(app, PDF_FIXTURE, "doc.pdf", [
|
||||
{ toolId: "rotate-pdf", settings: { angle: 90, range: "1-z" } },
|
||||
{ toolId: "grayscale-pdf", settings: {} },
|
||||
]);
|
||||
|
||||
// Pool is set at enqueue from modality, regardless of processing success
|
||||
const body = res.json();
|
||||
const jobId = body.jobId ?? body.id;
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
const parent = await db.query.jobs.findFirst({
|
||||
where: eq(schema.jobs.id, jobId),
|
||||
});
|
||||
expect(parent).toBeDefined();
|
||||
expect(parent?.pool).toBe("docs");
|
||||
});
|
||||
|
||||
// Needs ffmpeg: convert-audio must process for the response to carry the jobId
|
||||
// we look up. The docs-pool test above already proves the modality->pool
|
||||
// derivation (docs, not the old hardcoded "image") in environments without ffmpeg.
|
||||
it.skipIf(!ffmpegAvailable())(
|
||||
"routes an audio pipeline's parent job to the media pool",
|
||||
async () => {
|
||||
const res = await executePipeline(app, AUDIO_FIXTURE, "a2.mp3", [
|
||||
{ toolId: "convert-audio", settings: { format: "mp3", bitrateKbps: 192 } },
|
||||
]);
|
||||
|
||||
const body = res.json();
|
||||
const jobId = body.jobId ?? body.id;
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
const parent = await db.query.jobs.findFirst({
|
||||
where: eq(schema.jobs.id, jobId),
|
||||
});
|
||||
expect(parent).toBeDefined();
|
||||
expect(parent?.pool).toBe("media");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio pipeline chain (needs ffmpeg)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe.skipIf(!ffmpegAvailable())("Audio pipeline chain", () => {
|
||||
it("runs an audio pipeline: convert-audio -> volume-adjust", async () => {
|
||||
const res = await executePipeline(app, AUDIO_FIXTURE, "a.mp3", [
|
||||
{ toolId: "convert-audio", settings: { format: "mp3", bitrateKbps: 192 } },
|
||||
{ toolId: "volume-adjust", settings: { gainDb: 3 } },
|
||||
]);
|
||||
expect(res.statusCode).toBe(200);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document pipeline chain (needs qpdf + ghostscript)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe.skipIf(!qpdfAvailable() || !gsAvailable())("Document pipeline chain", () => {
|
||||
it("runs a document pipeline: rotate-pdf -> grayscale-pdf", async () => {
|
||||
const res = await executePipeline(app, PDF_FIXTURE, "d.pdf", [
|
||||
{ toolId: "rotate-pdf", settings: { angle: 90, range: "1-z" } },
|
||||
{ toolId: "grayscale-pdf", settings: {} },
|
||||
]);
|
||||
expect(res.statusCode).toBe(200);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File pipeline chain (no external tool -- ALWAYS runs)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("File pipeline chain", () => {
|
||||
it("runs a file pipeline: json-xml", async () => {
|
||||
const res = await executePipeline(app, Buffer.from('{"a":1}'), "x.json", [
|
||||
{ toolId: "json-xml", settings: {} },
|
||||
]);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-modality pipeline chain (video -> audio, needs ffmpeg)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe.skipIf(!ffmpegAvailable())("Cross-modality pipeline chain", () => {
|
||||
it("runs a cross-modality pipeline: extract-audio -> normalize-audio", async () => {
|
||||
const res = await executePipeline(app, VIDEO_FIXTURE, "v.mp4", [
|
||||
{ toolId: "extract-audio", settings: { format: "mp3" } },
|
||||
{ toolId: "normalize-audio", settings: {} },
|
||||
]);
|
||||
expect(res.statusCode).toBe(200);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TOOLS } from "../../../packages/shared/src/constants.js";
|
||||
import {
|
||||
modalityForExtension,
|
||||
toolInputModality,
|
||||
toolOutputModality,
|
||||
} from "../../../packages/shared/src/modality.js";
|
||||
|
||||
const tool = (id: string) => {
|
||||
const t = TOOLS.find((x) => x.id === id);
|
||||
if (!t) throw new Error(`tool ${id} not found`);
|
||||
return t;
|
||||
};
|
||||
|
||||
describe("modalityForExtension", () => {
|
||||
it("maps extensions to their owning modality", () => {
|
||||
expect(modalityForExtension(".png")).toBe("image");
|
||||
expect(modalityForExtension(".mp4")).toBe("video");
|
||||
expect(modalityForExtension(".mp3")).toBe("audio");
|
||||
expect(modalityForExtension(".pdf")).toBe("document");
|
||||
expect(modalityForExtension(".csv")).toBe("file");
|
||||
});
|
||||
it("is case-insensitive and tolerates a missing dot", () => {
|
||||
expect(modalityForExtension("PNG")).toBe("image");
|
||||
expect(modalityForExtension(".JPG")).toBe("image");
|
||||
});
|
||||
it("returns null for unknown extensions", () => {
|
||||
expect(modalityForExtension(".xyz")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolOutputModality", () => {
|
||||
it("defaults to the tool's modality", () => {
|
||||
expect(toolOutputModality(tool("resize"))).toBe("image");
|
||||
});
|
||||
it("uses the override for crossing tools", () => {
|
||||
expect(toolOutputModality(tool("extract-audio"))).toBe("audio");
|
||||
expect(toolOutputModality(tool("chart-maker"))).toBe("image");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInputModality", () => {
|
||||
it("returns the tool modality for normal tools", () => {
|
||||
expect(toolInputModality(tool("trim-video"))).toBe("video");
|
||||
expect(toolInputModality(tool("compress-pdf"))).toBe("document");
|
||||
});
|
||||
it("derives the real input modality from acceptedInputs", () => {
|
||||
expect(toolInputModality(tool("gif-to-video"))).toBe("image");
|
||||
expect(toolInputModality(tool("chart-maker"))).toBe("file");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeStepWarnings } from "@/lib/pipeline-compat";
|
||||
|
||||
describe("computeStepWarnings", () => {
|
||||
it("flags an image->video step mismatch", () => {
|
||||
const w = computeStepWarnings(["resize", "trim-video"], "image");
|
||||
expect(w[0]).toBeNull();
|
||||
expect(w[1]).toEqual({ expects: "video", receives: "image" });
|
||||
});
|
||||
it("does not flag a valid cross-modality chain", () => {
|
||||
const w = computeStepWarnings(["extract-audio", "volume-adjust"], "video");
|
||||
expect(w[0]).toBeNull();
|
||||
expect(w[1]).toBeNull();
|
||||
});
|
||||
it("flags the first step against the uploaded file modality", () => {
|
||||
const w = computeStepWarnings(["resize"], "video");
|
||||
expect(w[0]).toEqual({ expects: "image", receives: "video" });
|
||||
});
|
||||
it("does not flag the first step when no file is uploaded yet", () => {
|
||||
expect(computeStepWarnings(["resize"], null)[0]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConvertAudioControls } from "@/components/tools/convert-audio-settings";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ConvertAudioControls", () => {
|
||||
it("emits valid defaults on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ConvertAudioControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ format: "mp3", bitrateKbps: 192 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the chosen format on change", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ConvertAudioControls onChange={onChange} />);
|
||||
await userEvent.selectOptions(screen.getByLabelText(/output format/i), "flac");
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ format: "flac" }));
|
||||
});
|
||||
|
||||
it("emits the chosen bitrate on change", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ConvertAudioControls onChange={onChange} />);
|
||||
await userEvent.selectOptions(screen.getByLabelText(/bitrate/i), "320");
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ bitrateKbps: 320 }));
|
||||
});
|
||||
|
||||
it("initializes from incoming settings once", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ConvertAudioControls settings={{ format: "wav", bitrateKbps: 256 }} onChange={onChange} />,
|
||||
);
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ format: "wav", bitrateKbps: 256 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrimVideoControls", async () => {
|
||||
const { TrimVideoControls } = await import("@/components/tools/trim-video-settings");
|
||||
|
||||
it("emits valid defaults on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TrimVideoControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ startS: 0, endS: 0, precise: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RotateVideoControls", async () => {
|
||||
const { RotateVideoControls } = await import("@/components/tools/rotate-video-settings");
|
||||
|
||||
it("emits valid defaults on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RotateVideoControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ transform: "cw90" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("AudioChannelsControls", async () => {
|
||||
const { AudioChannelsControls } = await import("@/components/tools/audio-channels-settings");
|
||||
|
||||
it("emits valid defaults on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AudioChannelsControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ mode: "stereo-to-mono" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("RotatePdfControls", async () => {
|
||||
const { RotatePdfControls } = await import("@/components/tools/rotate-pdf-settings");
|
||||
|
||||
it("emits valid defaults on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RotatePdfControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ angle: 90, range: "1-z" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedactPdfControls", async () => {
|
||||
const { RedactPdfControls } = await import("@/components/tools/redact-pdf-settings");
|
||||
|
||||
it("emits empty terms array on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RedactPdfControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ terms: [], caseSensitive: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("splits comma-separated input into terms array", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RedactPdfControls onChange={onChange} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
await userEvent.type(input, "foo, bar, baz");
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.terms).toEqual(["foo", "bar", "baz"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NupPdfControls", async () => {
|
||||
const { NupPdfControls } = await import("@/components/tools/nup-pdf-settings");
|
||||
|
||||
it("emits perSheet as a number on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NupPdfControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ perSheet: 2 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChartMakerControls", async () => {
|
||||
const { ChartMakerControls } = await import("@/components/tools/chart-maker-settings");
|
||||
|
||||
it("emits valid defaults on mount", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChartMakerControls onChange={onChange} />);
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "bar", width: 960, height: 540 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user