feat(pipeline): add inline settings configuration for automation steps

Replace placeholder "default settings" message with actual form controls
when expanding pipeline steps. Each tool now shows its configurable fields
(dimensions, quality, colors, modes, etc.) directly in the pipeline builder.

Covers all 30+ tools with data-driven field definitions: number inputs,
select groups, checkboxes, color pickers, and conditional fields.
This commit is contained in:
Siddharth Kumar Sah
2026-03-24 21:38:16 +08:00
parent 432cc92471
commit adc7e1c037
2 changed files with 572 additions and 5 deletions
@@ -15,6 +15,7 @@ import {
import * as icons from "lucide-react";
import { TOOLS } from "@stirling-image/shared";
import { cn } from "@/lib/utils";
import { PipelineStepSettings } from "./pipeline-step-settings";
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
const PIPELINE_TOOLS = TOOLS.filter(
@@ -94,6 +95,13 @@ export function PipelineBuilder({
[steps, onStepsChange]
);
const updateStepSettings = useCallback(
(id: string, newSettings: Record<string, unknown>) => {
onStepsChange(steps.map((s) => (s.id === id ? { ...s, settings: newSettings } : s)));
},
[steps, onStepsChange]
);
const handleFileSelect = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
@@ -238,13 +246,15 @@ export function PipelineBuilder({
{/* Expanded settings */}
{isExpanded && (
<div className="border-t border-border p-3 bg-muted/10">
<p className="text-xs text-muted-foreground mb-2">
<div className="border-t border-border p-3 bg-muted/10 space-y-3">
<p className="text-xs text-muted-foreground">
{tool.description}
</p>
<p className="text-xs text-muted-foreground italic">
Default settings will be used. Configure via the API for advanced options.
</p>
<PipelineStepSettings
toolId={step.toolId}
settings={step.settings}
onChange={(s) => updateStepSettings(step.id, s)}
/>
</div>
)}
</div>
@@ -0,0 +1,557 @@
type FieldType = "number" | "select" | "boolean" | "text" | "color";
interface FieldDef {
key: string;
label: string;
type: FieldType;
min?: number;
max?: number;
step?: number;
placeholder?: string;
options?: { value: string; label: string }[];
defaultValue?: unknown;
showWhen?: (settings: Record<string, unknown>) => boolean;
}
const TOOL_FIELDS: Record<string, FieldDef[]> = {
resize: [
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Auto" },
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Auto" },
{ key: "percentage", label: "Scale (%)", type: "number", min: 1, placeholder: "Use instead of width/height" },
{
key: "fit",
label: "Fit Mode",
type: "select",
defaultValue: "contain",
options: [
{ value: "contain", label: "Fit inside" },
{ value: "cover", label: "Crop to fit" },
{ value: "fill", label: "Stretch" },
],
},
{ key: "withoutEnlargement", label: "Don't enlarge", type: "boolean", defaultValue: false },
],
crop: [
{ key: "left", label: "Left offset (px)", type: "number", min: 0, placeholder: "0" },
{ key: "top", label: "Top offset (px)", type: "number", min: 0, placeholder: "0" },
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Required" },
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Required" },
],
rotate: [
{ key: "angle", label: "Angle (degrees)", type: "number", min: -360, max: 360, step: 90, defaultValue: 0 },
{ key: "horizontal", label: "Flip horizontal", type: "boolean", defaultValue: false },
{ key: "vertical", label: "Flip vertical", type: "boolean", defaultValue: false },
],
convert: [
{
key: "format",
label: "Format",
type: "select",
options: [
{ value: "jpg", label: "JPEG" },
{ value: "png", label: "PNG" },
{ value: "webp", label: "WebP" },
{ value: "avif", label: "AVIF" },
{ value: "tiff", label: "TIFF" },
{ value: "gif", label: "GIF" },
],
},
{ key: "quality", label: "Quality (1-100)", type: "number", min: 1, max: 100, placeholder: "Auto" },
],
compress: [
{
key: "mode",
label: "Mode",
type: "select",
defaultValue: "quality",
options: [
{ value: "quality", label: "Quality" },
{ value: "targetSize", label: "Target size" },
],
},
{
key: "quality",
label: "Quality (1-100)",
type: "number",
min: 1,
max: 100,
placeholder: "80",
showWhen: (s) => s.mode !== "targetSize",
},
{
key: "targetSizeKb",
label: "Target size (KB)",
type: "number",
min: 1,
placeholder: "e.g. 200",
showWhen: (s) => s.mode === "targetSize",
},
],
"strip-metadata": [
{ key: "stripAll", label: "Strip all metadata", type: "boolean", defaultValue: true },
{
key: "stripExif",
label: "Strip EXIF",
type: "boolean",
defaultValue: false,
showWhen: (s) => !s.stripAll,
},
{
key: "stripGps",
label: "Strip GPS",
type: "boolean",
defaultValue: false,
showWhen: (s) => !s.stripAll,
},
{
key: "stripIcc",
label: "Strip ICC profile",
type: "boolean",
defaultValue: false,
showWhen: (s) => !s.stripAll,
},
{
key: "stripXmp",
label: "Strip XMP",
type: "boolean",
defaultValue: false,
showWhen: (s) => !s.stripAll,
},
],
"brightness-contrast": [
{ key: "brightness", label: "Brightness", type: "number", min: -100, max: 100, defaultValue: 0 },
{ key: "contrast", label: "Contrast", type: "number", min: -100, max: 100, defaultValue: 0 },
],
saturation: [
{ key: "saturation", label: "Saturation", type: "number", min: -100, max: 100, defaultValue: 0 },
],
"color-channels": [
{ key: "red", label: "Red", type: "number", min: 0, max: 200, defaultValue: 100 },
{ key: "green", label: "Green", type: "number", min: 0, max: 200, defaultValue: 100 },
{ key: "blue", label: "Blue", type: "number", min: 0, max: 200, defaultValue: 100 },
],
"color-effects": [
{
key: "effect",
label: "Effect",
type: "select",
defaultValue: "none",
options: [
{ value: "none", label: "None" },
{ value: "grayscale", label: "Grayscale" },
{ value: "sepia", label: "Sepia" },
{ value: "invert", label: "Invert" },
],
},
],
"replace-color": [
{ key: "sourceColor", label: "Source color", type: "color", defaultValue: "#FF0000" },
{ key: "targetColor", label: "Target color", type: "color", defaultValue: "#00FF00" },
{ key: "makeTransparent", label: "Make transparent instead", type: "boolean", defaultValue: false },
{ key: "tolerance", label: "Tolerance (0-255)", type: "number", min: 0, max: 255, defaultValue: 30 },
],
"watermark-text": [
{ key: "text", label: "Watermark text", type: "text", placeholder: "Your watermark" },
{ key: "fontSize", label: "Font size", type: "number", min: 8, max: 200, defaultValue: 48 },
{ key: "color", label: "Color", type: "color", defaultValue: "#000000" },
{ key: "opacity", label: "Opacity (%)", type: "number", min: 0, max: 100, defaultValue: 50 },
{
key: "position",
label: "Position",
type: "select",
defaultValue: "center",
options: [
{ value: "center", label: "Center" },
{ value: "top-left", label: "Top left" },
{ value: "top-right", label: "Top right" },
{ value: "bottom-left", label: "Bottom left" },
{ value: "bottom-right", label: "Bottom right" },
{ value: "tiled", label: "Tiled" },
],
},
{ key: "rotation", label: "Rotation (degrees)", type: "number", min: -360, max: 360, defaultValue: 0 },
],
"watermark-image": [
{
key: "position",
label: "Position",
type: "select",
defaultValue: "bottom-right",
options: [
{ value: "center", label: "Center" },
{ value: "top-left", label: "Top left" },
{ value: "top-right", label: "Top right" },
{ value: "bottom-left", label: "Bottom left" },
{ value: "bottom-right", label: "Bottom right" },
],
},
{ key: "opacity", label: "Opacity (%)", type: "number", min: 0, max: 100, defaultValue: 50 },
{ key: "scale", label: "Scale (%)", type: "number", min: 1, max: 100, defaultValue: 25 },
],
"text-overlay": [
{ key: "text", label: "Text", type: "text", placeholder: "Your text" },
{ key: "fontSize", label: "Font size", type: "number", min: 8, max: 200, defaultValue: 48 },
{ key: "color", label: "Color", type: "color", defaultValue: "#FFFFFF" },
{
key: "position",
label: "Position",
type: "select",
defaultValue: "bottom",
options: [
{ value: "top", label: "Top" },
{ value: "center", label: "Center" },
{ value: "bottom", label: "Bottom" },
],
},
{ key: "backgroundBox", label: "Background box", type: "boolean", defaultValue: false },
{
key: "backgroundColor",
label: "Box color",
type: "color",
defaultValue: "#000000",
showWhen: (s) => !!s.backgroundBox,
},
{ key: "shadow", label: "Text shadow", type: "boolean", defaultValue: true },
],
border: [
{ key: "borderWidth", label: "Width (px)", type: "number", min: 0, max: 200, defaultValue: 10 },
{ key: "borderColor", label: "Color", type: "color", defaultValue: "#000000" },
{ key: "cornerRadius", label: "Corner radius", type: "number", min: 0, max: 500, defaultValue: 0 },
{ key: "padding", label: "Padding (px)", type: "number", min: 0, max: 200, defaultValue: 0 },
{ key: "shadowBlur", label: "Shadow blur", type: "number", min: 0, max: 50, defaultValue: 0 },
{ key: "shadowColor", label: "Shadow color", type: "color", defaultValue: "#00000080" },
],
split: [
{ key: "columns", label: "Columns", type: "number", min: 1, max: 10, defaultValue: 2 },
{ key: "rows", label: "Rows", type: "number", min: 1, max: 10, defaultValue: 2 },
],
"blur-faces": [
{ key: "blurRadius", label: "Blur radius", type: "number", min: 1, max: 100, defaultValue: 30 },
{ key: "sensitivity", label: "Sensitivity (0-1)", type: "number", min: 0, max: 1, step: 0.1, defaultValue: 0.5 },
],
upscale: [
{
key: "scale",
label: "Scale factor",
type: "select",
defaultValue: "2",
options: [
{ value: "2", label: "2x" },
{ value: "3", label: "3x" },
{ value: "4", label: "4x" },
],
},
],
"smart-crop": [
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Required" },
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Required" },
],
vectorize: [
{
key: "colorMode",
label: "Color mode",
type: "select",
defaultValue: "bw",
options: [
{ value: "bw", label: "Black & White" },
{ value: "color", label: "Color" },
],
},
{ key: "threshold", label: "Threshold (0-255)", type: "number", min: 0, max: 255, defaultValue: 128 },
{
key: "detail",
label: "Detail",
type: "select",
defaultValue: "medium",
options: [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
},
],
"svg-to-raster": [
{ key: "width", label: "Width (px)", type: "number", min: 1, max: 8192, defaultValue: 1024 },
{ key: "height", label: "Height (px)", type: "number", min: 1, max: 8192, placeholder: "Auto" },
{
key: "outputFormat",
label: "Format",
type: "select",
defaultValue: "png",
options: [
{ value: "png", label: "PNG" },
{ value: "jpg", label: "JPEG" },
{ value: "webp", label: "WebP" },
],
},
],
"gif-tools": [
{ key: "width", label: "Width (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
{ key: "height", label: "Height (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
{ key: "extractFrame", label: "Extract frame #", type: "number", min: 0, placeholder: "All frames" },
{ key: "optimize", label: "Optimize", type: "boolean", defaultValue: false },
],
"image-to-pdf": [
{
key: "pageSize",
label: "Page size",
type: "select",
defaultValue: "A4",
options: [
{ value: "A4", label: "A4" },
{ value: "Letter", label: "Letter" },
{ value: "A3", label: "A3" },
{ value: "A5", label: "A5" },
],
},
{
key: "orientation",
label: "Orientation",
type: "select",
defaultValue: "portrait",
options: [
{ value: "portrait", label: "Portrait" },
{ value: "landscape", label: "Landscape" },
],
},
{ key: "margin", label: "Margin (mm)", type: "number", min: 0, max: 100, defaultValue: 20 },
],
"bulk-rename": [
{ key: "pattern", label: "Pattern", type: "text", placeholder: "image-{{index}}", defaultValue: "image-{{index}}" },
{ key: "startIndex", label: "Start index", type: "number", min: 0, defaultValue: 1 },
],
ocr: [
{
key: "engine",
label: "Engine",
type: "select",
defaultValue: "tesseract",
options: [
{ value: "tesseract", label: "Tesseract" },
{ value: "paddleocr", label: "PaddleOCR" },
],
},
{
key: "language",
label: "Language",
type: "select",
defaultValue: "en",
options: [
{ value: "en", label: "English" },
{ value: "de", label: "German" },
{ value: "fr", label: "French" },
{ value: "es", label: "Spanish" },
{ value: "zh", label: "Chinese" },
{ value: "ja", label: "Japanese" },
{ value: "ko", label: "Korean" },
],
},
],
"qr-generate": [
{ key: "text", label: "Content", type: "text", placeholder: "URL or text" },
{ key: "size", label: "Size (px)", type: "number", min: 100, max: 2000, defaultValue: 400 },
{
key: "errorCorrection",
label: "Error correction",
type: "select",
defaultValue: "M",
options: [
{ value: "L", label: "Low (7%)" },
{ value: "M", label: "Medium (15%)" },
{ value: "Q", label: "Quartile (25%)" },
{ value: "H", label: "High (30%)" },
],
},
{ key: "foreground", label: "Foreground", type: "color", defaultValue: "#000000" },
{ key: "background", label: "Background", type: "color", defaultValue: "#FFFFFF" },
],
"remove-background": [],
favicon: [],
"color-palette": [],
"barcode-read": [],
info: [],
"erase-object": [],
};
interface PipelineStepSettingsProps {
toolId: string;
settings: Record<string, unknown>;
onChange: (settings: Record<string, unknown>) => void;
}
export function PipelineStepSettings({ toolId, settings, onChange }: PipelineStepSettingsProps) {
const fields = TOOL_FIELDS[toolId];
if (!fields || fields.length === 0) {
return (
<p className="text-xs text-muted-foreground italic">
No configurable settings. Defaults will be used.
</p>
);
}
const updateField = (key: string, value: unknown) => {
const next = { ...settings };
if (value === undefined || value === "") {
delete next[key];
} else {
next[key] = value;
}
onChange(next);
};
const visibleFields = fields.filter((f) => !f.showWhen || f.showWhen(settings));
return (
<div className="space-y-3">
{visibleFields.map((field) => {
const raw = settings[field.key];
const value = raw !== undefined ? raw : field.defaultValue;
switch (field.type) {
case "number":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<input
type="number"
value={value != null && value !== "" ? Number(value) : ""}
onChange={(e) =>
updateField(field.key, e.target.value === "" ? undefined : Number(e.target.value))
}
min={field.min}
max={field.max}
step={field.step}
placeholder={field.placeholder}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
);
case "text":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<input
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
placeholder={field.placeholder}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
);
case "select": {
const opts = field.options!;
// Use button group for <= 4 options, dropdown for more
if (opts.length <= 4) {
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<div className="flex gap-1 mt-0.5">
{opts.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => updateField(field.key, opt.value)}
className={`flex-1 text-xs py-1.5 rounded transition-colors ${
String(value) === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
);
}
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<select
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
{!value && <option value="">Select...</option>}
{opts.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
case "boolean":
return (
<label key={field.key} className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => updateField(field.key, e.target.checked)}
className="rounded"
/>
{field.label}
</label>
);
case "color":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<div className="flex gap-2 mt-0.5">
<input
type="color"
value={String(value || "#000000").slice(0, 7)}
onChange={(e) => updateField(field.key, e.target.value)}
className="h-8 w-8 rounded border border-border cursor-pointer bg-background"
/>
<input
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
placeholder="#000000"
className="flex-1 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground font-mono"
/>
</div>
</div>
);
default:
return null;
}
})}
</div>
);
}