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:
SnapOtter
2026-06-24 00:21:41 +08:00
committed by GitHub
parent 35e18d8b79
commit a53038ed96
69 changed files with 3258 additions and 55 deletions
+31 -11
View File
@@ -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>
);
}
+27
View File
@@ -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;
});
}
+63 -5
View File
@@ -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">
+14
View File
@@ -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",
},
{
+3
View File
@@ -3199,6 +3199,9 @@ export const ar: TranslationKeys = {
noStepsHeading: "لا توجد خطوات بعد",
searchToolsPlaceholder: "بحث في الأدوات...",
step: "خطوة",
modalityWarning: "عدم توافق النوع",
modalityWarningTooltip:
"تتوقع هذه الخطوة {expected} لكنها تتلقى {received}؛ قد تفشل عند تشغيل المسار.",
},
nav: {
tools: "الأدوات",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -3194,6 +3194,9 @@ export const hi: TranslationKeys = {
noStepsHeading: "अभी तक कोई चरण नहीं",
searchToolsPlaceholder: "टूल खोजें...",
step: "स्टेप",
modalityWarning: "मोडैलिटी मेल नहीं खाती",
modalityWarningTooltip:
"यह चरण {expected} की अपेक्षा करता है लेकिन {received} प्राप्त करता है; पाइपलाइन चलाने पर यह विफल हो सकता है।",
},
nav: {
tools: "टूल्स",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -3171,6 +3171,9 @@ export const ja: TranslationKeys = {
noStepsHeading: "ステップがありません",
searchToolsPlaceholder: "ツールを検索...",
step: "ステップ",
modalityWarning: "モダリティ不一致",
modalityWarningTooltip:
"このステップは {expected} を想定していますが {received} を受け取ります。パイプラインの実行時に失敗する可能性があります。",
},
nav: {
tools: "ツール",
+3
View File
@@ -3154,6 +3154,9 @@ export const ko: TranslationKeys = {
noStepsHeading: "단계가 없습니다",
searchToolsPlaceholder: "도구 검색...",
step: "단계",
modalityWarning: "모달리티 불일치",
modalityWarningTooltip:
"이 단계는 {expected}을(를) 기대하지만 {received}을(를) 받습니다. 파이프라인을 실행하면 실패할 수 있습니다.",
},
nav: {
tools: "도구",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -3223,6 +3223,9 @@ export const ru: TranslationKeys = {
noStepsHeading: "Шагов пока нет",
searchToolsPlaceholder: "Поиск инструментов...",
step: "Шаг",
modalityWarning: "Несоответствие модальности",
modalityWarningTooltip:
"Этот шаг ожидает {expected}, но получает {received}; он может завершиться ошибкой при запуске конвейера.",
},
nav: {
tools: "Инструменты",
+3
View File
@@ -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",
+2
View File
@@ -3180,6 +3180,8 @@ export const th: TranslationKeys = {
noStepsHeading: "ยังไม่มีขั้นตอน",
searchToolsPlaceholder: "ค้นหาเครื่องมือ...",
step: "ขั้นตอน",
modalityWarning: "ประเภทไม่ตรงกัน",
modalityWarningTooltip: "ขั้นตอนนี้คาดหวัง {expected} แต่ได้รับ {received} อาจล้มเหลวเมื่อคุณเรียกใช้ไปป์ไลน์",
},
nav: {
tools: "เครื่องมือ",
+3
View File
@@ -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",
+3
View File
@@ -3225,6 +3225,9 @@ export const uk: TranslationKeys = {
noStepsHeading: "Кроків поки немає",
searchToolsPlaceholder: "Пошук інструментів...",
step: "Крок",
modalityWarning: "Невідповідність модальності",
modalityWarningTooltip:
"Цей крок очікує {expected}, але отримує {received}; він може завершитися помилкою під час запуску конвеєра.",
},
nav: {
tools: "Інструменти",
+3
View File
@@ -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ụ",
+2
View File
@@ -3130,6 +3130,8 @@ export const zhCN: TranslationKeys = {
noStepsHeading: "暂无步骤",
searchToolsPlaceholder: "搜索工具...",
step: "步骤",
modalityWarning: "模态不匹配",
modalityWarningTooltip: "此步骤需要 {expected},但接收到 {received};运行管道时可能会失败。",
},
nav: {
tools: "工具",
+2
View File
@@ -3129,6 +3129,8 @@ export const zhTW: TranslationKeys = {
noStepsHeading: "尚無步驟",
searchToolsPlaceholder: "搜尋工具...",
step: "步驟",
modalityWarning: "模態不相符",
modalityWarningTooltip: "此步驟需要 {expected},但收到 {received};執行管線時可能會失敗。",
},
nav: {
tools: "工具",
+39
View File
@@ -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;
}
+2
View File
@@ -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");
});
});
+22
View 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();
});
});
+130
View File
@@ -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 }),
);
});
});