From a53038ed9610a964090a98f6973a25449a1e4a9f Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 24 Jun 2026 00:21:41 +0800 Subject: [PATCH] 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 --- apps/api/src/routes/pipeline.ts | 42 +++-- .../components/tools/aspect-pad-settings.tsx | 64 ++++++- .../tools/audio-channels-settings.tsx | 48 +++++- .../tools/audio-metadata-settings.tsx | 91 +++++++++- .../components/tools/audio-speed-settings.tsx | 48 +++++- .../components/tools/blur-pad-settings.tsx | 67 +++++++- .../components/tools/change-fps-settings.tsx | 48 +++++- .../components/tools/chart-maker-settings.tsx | 104 +++++++++++- .../tools/compress-video-settings.tsx | 66 +++++++- .../tools/convert-audio-settings.tsx | 69 +++++++- .../tools/convert-document-settings.tsx | 53 +++++- .../tools/convert-presentation-settings.tsx | 53 +++++- .../tools/convert-spreadsheet-settings.tsx | 53 +++++- .../tools/convert-video-settings.tsx | 65 ++++++- .../components/tools/crop-video-settings.tsx | 91 +++++++++- .../tools/epub-convert-settings.tsx | 49 +++++- .../tools/extract-pages-settings.tsx | 46 ++++- .../src/components/tools/nup-pdf-settings.tsx | 52 +++++- .../tools/organize-pdf-settings.tsx | 46 ++++- .../tools/pdf-metadata-settings.tsx | 96 ++++++++++- .../src/components/tools/pipeline-builder.tsx | 33 +++- .../tools/pipeline-step-settings.tsx | 110 ++++++++++++ .../components/tools/pipeline-step-summary.ts | 144 ++++++++++++++++ .../components/tools/pitch-shift-settings.tsx | 48 +++++- .../components/tools/redact-pdf-settings.tsx | 63 ++++++- .../tools/remove-pages-settings.tsx | 46 ++++- .../tools/resize-video-settings.tsx | 95 ++++++++++- .../tools/ringtone-maker-settings.tsx | 64 ++++++- .../components/tools/rotate-pdf-settings.tsx | 63 ++++++- .../tools/rotate-video-settings.tsx | 50 +++++- .../components/tools/trim-audio-settings.tsx | 63 ++++++- .../components/tools/trim-video-settings.tsx | 74 +++++++- .../components/tools/video-color-settings.tsx | 99 ++++++++++- .../components/tools/video-speed-settings.tsx | 59 ++++++- .../tools/video-to-gif-settings.tsx | 98 ++++++++++- .../tools/video-to-webp-settings.tsx | 93 +++++++++- .../tools/volume-adjust-settings.tsx | 48 +++++- .../tools/watermark-pdf-settings.tsx | 120 ++++++++++++- .../tools/watermark-video-settings.tsx | 120 ++++++++++++- apps/web/src/lib/pipeline-compat.ts | 27 +++ apps/web/src/pages/automate-page.tsx | 68 +++++++- packages/shared/src/constants.ts | 14 ++ packages/shared/src/i18n/ar.ts | 3 + packages/shared/src/i18n/de.ts | 3 + packages/shared/src/i18n/en.ts | 3 + packages/shared/src/i18n/es.ts | 3 + packages/shared/src/i18n/fr.ts | 3 + packages/shared/src/i18n/hi.ts | 3 + packages/shared/src/i18n/id.ts | 3 + packages/shared/src/i18n/it.ts | 3 + packages/shared/src/i18n/ja.ts | 3 + packages/shared/src/i18n/ko.ts | 3 + packages/shared/src/i18n/nl.ts | 3 + packages/shared/src/i18n/pl.ts | 3 + packages/shared/src/i18n/pt-BR.ts | 3 + packages/shared/src/i18n/ru.ts | 3 + packages/shared/src/i18n/sv.ts | 3 + packages/shared/src/i18n/th.ts | 2 + packages/shared/src/i18n/tr.ts | 3 + packages/shared/src/i18n/uk.ts | 3 + packages/shared/src/i18n/vi.ts | 3 + packages/shared/src/i18n/zh-CN.ts | 2 + packages/shared/src/i18n/zh-TW.ts | 2 + packages/shared/src/modality.ts | 39 +++++ packages/shared/src/types.ts | 2 + .../platform/pipeline-multimodal.test.ts | 159 ++++++++++++++++++ tests/unit/shared/modality-helpers.test.ts | 51 ++++++ tests/unit/web/pipeline-compat.test.ts | 22 +++ tests/unit/web/pipeline-controls.test.tsx | 130 ++++++++++++++ 69 files changed, 3258 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/lib/pipeline-compat.ts create mode 100644 tests/integration/platform/pipeline-multimodal.test.ts create mode 100644 tests/unit/shared/modality-helpers.test.ts create mode 100644 tests/unit/web/pipeline-compat.test.ts create mode 100644 tests/unit/web/pipeline-controls.test.tsx diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 7605d63b..7642daf6 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -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 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 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 ); } + +export interface AspectPadControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function AspectPadControls({ settings: initial, onChange }: AspectPadControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["aspect-pad"]; + const [target, setTarget] = useState("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 ( +
+
+ + +
+
+ + setColor(e.target.value)} + className="w-full mt-0.5 h-8 rounded border border-border bg-background" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/audio-channels-settings.tsx b/apps/web/src/components/tools/audio-channels-settings.tsx index 5bf92097..e3316551 100644 --- a/apps/web/src/components/tools/audio-channels-settings.tsx +++ b/apps/web/src/components/tools/audio-channels-settings.tsx @@ -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() { ); } + +export interface AudioChannelsControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function AudioChannelsControls({ settings: initial, onChange }: AudioChannelsControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["audio-channels"]; + const [mode, setMode] = useState("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 ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/audio-metadata-settings.tsx b/apps/web/src/components/tools/audio-metadata-settings.tsx index bf69351d..f4c20e16 100644 --- a/apps/web/src/components/tools/audio-metadata-settings.tsx +++ b/apps/web/src/components/tools/audio-metadata-settings.tsx @@ -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() { ); } + +export interface AudioMetadataControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 = { strip }; + if (title) out.title = title; + if (artist) out.artist = artist; + if (album) out.album = album; + onChangeRef.current?.(out); + }, [strip, title, artist, album]); + + return ( +
+ +
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/audio-speed-settings.tsx b/apps/web/src/components/tools/audio-speed-settings.tsx index ddd682b8..ae9b042f 100644 --- a/apps/web/src/components/tools/audio-speed-settings.tsx +++ b/apps/web/src/components/tools/audio-speed-settings.tsx @@ -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() { ); } + +export interface AudioSpeedControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/blur-pad-settings.tsx b/apps/web/src/components/tools/blur-pad-settings.tsx index b8ec1761..797b6f65 100644 --- a/apps/web/src/components/tools/blur-pad-settings.tsx +++ b/apps/web/src/components/tools/blur-pad-settings.tsx @@ -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() { ); } + +export interface BlurPadControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function BlurPadControls({ settings: initial, onChange }: BlurPadControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["blur-pad"]; + const [target, setTarget] = useState("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 ( +
+
+ + +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/change-fps-settings.tsx b/apps/web/src/components/tools/change-fps-settings.tsx index 1057a32b..4140c5ca 100644 --- a/apps/web/src/components/tools/change-fps-settings.tsx +++ b/apps/web/src/components/tools/change-fps-settings.tsx @@ -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() { ); } + +export interface ChangeFpsControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/chart-maker-settings.tsx b/apps/web/src/components/tools/chart-maker-settings.tsx index b7cde991..08e349cb 100644 --- a/apps/web/src/components/tools/chart-maker-settings.tsx +++ b/apps/web/src/components/tools/chart-maker-settings.tsx @@ -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() { ); } + +type ChartKind = "bar" | "line" | "pie"; + +export interface ChartMakerControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function ChartMakerControls({ settings: initial, onChange }: ChartMakerControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["chart-maker"]; + const [kind, setKind] = useState("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 = { kind, width, height }; + if (title.trim()) out.title = title.trim(); + onChangeRef.current?.(out); + }, [kind, title, width, height]); + + return ( +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/compress-video-settings.tsx b/apps/web/src/components/tools/compress-video-settings.tsx index c4dbccc7..80fcbf2d 100644 --- a/apps/web/src/components/tools/compress-video-settings.tsx +++ b/apps/web/src/components/tools/compress-video-settings.tsx @@ -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() { ); } + +export interface CompressVideoControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function CompressVideoControls({ settings: initial, onChange }: CompressVideoControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["compress-video"]; + const [quality, setQuality] = useState("balanced"); + const [resolution, setResolution] = useState("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 ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/convert-audio-settings.tsx b/apps/web/src/components/tools/convert-audio-settings.tsx index 0843b264..14c29ad0 100644 --- a/apps/web/src/components/tools/convert-audio-settings.tsx +++ b/apps/web/src/components/tools/convert-audio-settings.tsx @@ -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() { ); } + +export interface ConvertAudioControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function ConvertAudioControls({ settings: initial, onChange }: ConvertAudioControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["convert-audio"]; + const [outFormat, setOutFormat] = useState("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 ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/convert-document-settings.tsx b/apps/web/src/components/tools/convert-document-settings.tsx index 0507b5b2..75870fbb 100644 --- a/apps/web/src/components/tools/convert-document-settings.tsx +++ b/apps/web/src/components/tools/convert-document-settings.tsx @@ -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() { ); } + +export interface ConvertDocumentControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function ConvertDocumentControls({ + settings: initial, + onChange, +}: ConvertDocumentControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["convert-document"]; + const [outFormat, setOutFormat] = useState("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 ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/convert-presentation-settings.tsx b/apps/web/src/components/tools/convert-presentation-settings.tsx index bbb7b44a..1c709da4 100644 --- a/apps/web/src/components/tools/convert-presentation-settings.tsx +++ b/apps/web/src/components/tools/convert-presentation-settings.tsx @@ -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() { ); } + +export interface ConvertPresentationControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function ConvertPresentationControls({ + settings: initial, + onChange, +}: ConvertPresentationControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["convert-presentation"]; + const [outFormat, setOutFormat] = useState("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 ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/convert-spreadsheet-settings.tsx b/apps/web/src/components/tools/convert-spreadsheet-settings.tsx index fd7fc3c5..ab397363 100644 --- a/apps/web/src/components/tools/convert-spreadsheet-settings.tsx +++ b/apps/web/src/components/tools/convert-spreadsheet-settings.tsx @@ -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() { ); } + +export interface ConvertSpreadsheetControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function ConvertSpreadsheetControls({ + settings: initial, + onChange, +}: ConvertSpreadsheetControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["convert-spreadsheet"]; + const [outFormat, setOutFormat] = useState("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 ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/convert-video-settings.tsx b/apps/web/src/components/tools/convert-video-settings.tsx index aa761ef9..cfb8867f 100644 --- a/apps/web/src/components/tools/convert-video-settings.tsx +++ b/apps/web/src/components/tools/convert-video-settings.tsx @@ -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() { ); } + +export interface ConvertVideoControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function ConvertVideoControls({ settings: initial, onChange }: ConvertVideoControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["convert-video"]; + const [outFormat, setOutFormat] = useState("mp4"); + const [quality, setQuality] = useState("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 ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/crop-video-settings.tsx b/apps/web/src/components/tools/crop-video-settings.tsx index 1147cf46..e343fd28 100644 --- a/apps/web/src/components/tools/crop-video-settings.tsx +++ b/apps/web/src/components/tools/crop-video-settings.tsx @@ -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() { ); } + +export interface CropVideoControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/epub-convert-settings.tsx b/apps/web/src/components/tools/epub-convert-settings.tsx index 8e995aca..c37cfde8 100644 --- a/apps/web/src/components/tools/epub-convert-settings.tsx +++ b/apps/web/src/components/tools/epub-convert-settings.tsx @@ -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() { ); } + +export interface EpubConvertControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function EpubConvertControls({ settings: initial, onChange }: EpubConvertControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["epub-convert"]; + const [outFormat, setOutFormat] = useState("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 ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/extract-pages-settings.tsx b/apps/web/src/components/tools/extract-pages-settings.tsx index 9aafe15c..c3dae93f 100644 --- a/apps/web/src/components/tools/extract-pages-settings.tsx +++ b/apps/web/src/components/tools/extract-pages-settings.tsx @@ -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() { ); } + +export interface ExtractPagesControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +

{s.rangeHint}

+
+
+ ); +} diff --git a/apps/web/src/components/tools/nup-pdf-settings.tsx b/apps/web/src/components/tools/nup-pdf-settings.tsx index 7f2ea041..bb410af0 100644 --- a/apps/web/src/components/tools/nup-pdf-settings.tsx +++ b/apps/web/src/components/tools/nup-pdf-settings.tsx @@ -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() { ); } + +export interface NupPdfControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function NupPdfControls({ settings: initial, onChange }: NupPdfControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["nup-pdf"]; + const [perSheet, setPerSheet] = useState(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 ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/organize-pdf-settings.tsx b/apps/web/src/components/tools/organize-pdf-settings.tsx index aab82e7c..ee78971e 100644 --- a/apps/web/src/components/tools/organize-pdf-settings.tsx +++ b/apps/web/src/components/tools/organize-pdf-settings.tsx @@ -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() { ); } + +export interface OrganizePdfControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +

{s.orderHint}

+
+
+ ); +} diff --git a/apps/web/src/components/tools/pdf-metadata-settings.tsx b/apps/web/src/components/tools/pdf-metadata-settings.tsx index 70ba9504..f7602542 100644 --- a/apps/web/src/components/tools/pdf-metadata-settings.tsx +++ b/apps/web/src/components/tools/pdf-metadata-settings.tsx @@ -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() { ); } + +export interface PdfMetadataControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 = {}; + 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/pipeline-builder.tsx b/apps/web/src/components/tools/pipeline-builder.tsx index 5a1b39ba..ba0f6ee5 100644 --- a/apps/web/src/components/tools/pipeline-builder.tsx +++ b/apps/web/src/components/tools/pipeline-builder.tsx @@ -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) => void; @@ -41,6 +44,7 @@ interface SortableStepProps { step: PipelineStep; index: number; isExpanded: boolean; + warning?: StepWarning | null; onToggle: () => void; onRemove: () => void; onUpdateSettings: (settings: Record) => 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)} + {/* Modality mismatch warning */} + {warning && ( + + + {t.automate.modalityWarning} + + )} + {/* Settings summary when collapsed */} {!isExpanded && summary && ( {summary} @@ -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 (
- +

{t.automate.noStepsHeading}

{t.automate.addToolsPrompt}

@@ -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)} diff --git a/apps/web/src/components/tools/pipeline-step-settings.tsx b/apps/web/src/components/tools/pipeline-step-settings.tsx index de994e05..fd6e7847 100644 --- a/apps/web/src/components/tools/pipeline-step-settings.tsx +++ b/apps/web/src/components/tools/pipeline-step-settings.tsx @@ -48,6 +48,116 @@ const CONTROLS: Record> "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"]); diff --git a/apps/web/src/components/tools/pipeline-step-summary.ts b/apps/web/src/components/tools/pipeline-step-summary.ts index 1732940e..6a9b00e9 100644 --- a/apps/web/src/components/tools/pipeline-step-summary.ts +++ b/apps/web/src/components/tools/pipeline-step-summary.ts @@ -57,6 +57,150 @@ export function getSettingsSummary(toolId: string, settings: Record 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 ""; } diff --git a/apps/web/src/components/tools/pitch-shift-settings.tsx b/apps/web/src/components/tools/pitch-shift-settings.tsx index 57ae0a45..fef0c8a2 100644 --- a/apps/web/src/components/tools/pitch-shift-settings.tsx +++ b/apps/web/src/components/tools/pitch-shift-settings.tsx @@ -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() {
); } + +export interface PitchShiftControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/redact-pdf-settings.tsx b/apps/web/src/components/tools/redact-pdf-settings.tsx index 6b5fbda5..6489115e 100644 --- a/apps/web/src/components/tools/redact-pdf-settings.tsx +++ b/apps/web/src/components/tools/redact-pdf-settings.tsx @@ -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() { ); } + +export interface RedactPdfControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ setCaseSensitive(e.target.checked)} + className="rounded border-border" + /> + +
+
+ ); +} diff --git a/apps/web/src/components/tools/remove-pages-settings.tsx b/apps/web/src/components/tools/remove-pages-settings.tsx index a19f716c..a2d12514 100644 --- a/apps/web/src/components/tools/remove-pages-settings.tsx +++ b/apps/web/src/components/tools/remove-pages-settings.tsx @@ -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() { ); } + +export interface RemovePagesControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +

{s.pagesHint}

+
+
+ ); +} diff --git a/apps/web/src/components/tools/resize-video-settings.tsx b/apps/web/src/components/tools/resize-video-settings.tsx index 66683260..b7bf9552 100644 --- a/apps/web/src/components/tools/resize-video-settings.tsx +++ b/apps/web/src/components/tools/resize-video-settings.tsx @@ -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() { ); } + +export interface ResizeVideoControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function ResizeVideoControls({ settings: initial, onChange }: ResizeVideoControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["resize-video"]; + const [preset, setPreset] = useState("custom"); + const [width, setWidth] = useState(""); + const [height, setHeight] = useState(""); + + 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 = { 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 ( +
+
+ + +
+ {isCustom && ( + <> +
+ + 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" + /> +
+
+ + 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" + /> +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/tools/ringtone-maker-settings.tsx b/apps/web/src/components/tools/ringtone-maker-settings.tsx index be320af3..dd72d52e 100644 --- a/apps/web/src/components/tools/ringtone-maker-settings.tsx +++ b/apps/web/src/components/tools/ringtone-maker-settings.tsx @@ -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() { ); } + +export interface RingtoneMakerControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/rotate-pdf-settings.tsx b/apps/web/src/components/tools/rotate-pdf-settings.tsx index 5cddbb0d..081a4423 100644 --- a/apps/web/src/components/tools/rotate-pdf-settings.tsx +++ b/apps/web/src/components/tools/rotate-pdf-settings.tsx @@ -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() { ); } + +export interface RotatePdfControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function RotatePdfControls({ settings: initial, onChange }: RotatePdfControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["rotate-pdf"]; + const [angle, setAngle] = useState(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 ( +
+
+ + +
+
+ + 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" + /> +

{s.rangeHint}

+
+
+ ); +} diff --git a/apps/web/src/components/tools/rotate-video-settings.tsx b/apps/web/src/components/tools/rotate-video-settings.tsx index 6079aa47..d4a22aa5 100644 --- a/apps/web/src/components/tools/rotate-video-settings.tsx +++ b/apps/web/src/components/tools/rotate-video-settings.tsx @@ -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() { ); } + +export interface RotateVideoControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; +} + +export function RotateVideoControls({ settings: initial, onChange }: RotateVideoControlsProps) { + const { t } = useTranslation(); + const s = t.toolSettings["rotate-video"]; + const [transform, setTransform] = useState("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 ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/tools/trim-audio-settings.tsx b/apps/web/src/components/tools/trim-audio-settings.tsx index 24f44549..b1767a23 100644 --- a/apps/web/src/components/tools/trim-audio-settings.tsx +++ b/apps/web/src/components/tools/trim-audio-settings.tsx @@ -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() { ); } + +export interface TrimAudioControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/trim-video-settings.tsx b/apps/web/src/components/tools/trim-video-settings.tsx index d5acffc7..d1b0ed59 100644 --- a/apps/web/src/components/tools/trim-video-settings.tsx +++ b/apps/web/src/components/tools/trim-video-settings.tsx @@ -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() { ); } + +export interface TrimVideoControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+ ); +} diff --git a/apps/web/src/components/tools/video-color-settings.tsx b/apps/web/src/components/tools/video-color-settings.tsx index 8fba258d..52e421bb 100644 --- a/apps/web/src/components/tools/video-color-settings.tsx +++ b/apps/web/src/components/tools/video-color-settings.tsx @@ -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() { ); } + +export interface VideoColorControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/video-speed-settings.tsx b/apps/web/src/components/tools/video-speed-settings.tsx index efb71c55..eabcabe6 100644 --- a/apps/web/src/components/tools/video-speed-settings.tsx +++ b/apps/web/src/components/tools/video-speed-settings.tsx @@ -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() { ); } + +export interface VideoSpeedControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+ +
+ ); +} diff --git a/apps/web/src/components/tools/video-to-gif-settings.tsx b/apps/web/src/components/tools/video-to-gif-settings.tsx index 21943f48..2b2c0cb5 100644 --- a/apps/web/src/components/tools/video-to-gif-settings.tsx +++ b/apps/web/src/components/tools/video-to-gif-settings.tsx @@ -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() { ); } + +export interface VideoToGifControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/video-to-webp-settings.tsx b/apps/web/src/components/tools/video-to-webp-settings.tsx index 6bd88d65..f5670c0a 100644 --- a/apps/web/src/components/tools/video-to-webp-settings.tsx +++ b/apps/web/src/components/tools/video-to-webp-settings.tsx @@ -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() { ); } + +export interface VideoToWebpControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+ ); +} diff --git a/apps/web/src/components/tools/volume-adjust-settings.tsx b/apps/web/src/components/tools/volume-adjust-settings.tsx index 88ed83dd..ce98f11f 100644 --- a/apps/web/src/components/tools/volume-adjust-settings.tsx +++ b/apps/web/src/components/tools/volume-adjust-settings.tsx @@ -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() { ); } + +export interface VolumeAdjustControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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 ( +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/watermark-pdf-settings.tsx b/apps/web/src/components/tools/watermark-pdf-settings.tsx index d48c71f2..8e44cc6b 100644 --- a/apps/web/src/components/tools/watermark-pdf-settings.tsx +++ b/apps/web/src/components/tools/watermark-pdf-settings.tsx @@ -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() { ); } + +export interface WatermarkPdfControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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("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 ( +
+
+ + 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" + /> +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ ); +} diff --git a/apps/web/src/components/tools/watermark-video-settings.tsx b/apps/web/src/components/tools/watermark-video-settings.tsx index dd4991ab..193396d2 100644 --- a/apps/web/src/components/tools/watermark-video-settings.tsx +++ b/apps/web/src/components/tools/watermark-video-settings.tsx @@ -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() { ); } + +export interface WatermarkVideoControlsProps { + settings?: Record; + onChange?: (settings: Record) => 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("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 ( +
+
+ + 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" + /> +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + setColor(e.target.value)} + className="w-full mt-0.5 h-8 rounded border border-border bg-background" + /> +
+
+ ); +} diff --git a/apps/web/src/lib/pipeline-compat.ts b/apps/web/src/lib/pipeline-compat.ts new file mode 100644 index 00000000..263f8678 --- /dev/null +++ b/apps/web/src/lib/pipeline-compat.ts @@ -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; + }); +} diff --git a/apps/web/src/pages/automate-page.tsx b/apps/web/src/pages/automate-page.tsx index 91816016..f75648a5 100644 --- a/apps/web/src/pages/automate-page.tsx +++ b/apps/web/src/pages/automate-page.tsx @@ -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() {
- + {(() => { + const Icon = previewIcon(kind); + return ; + })()}

{fname}

@@ -537,7 +574,7 @@ export function AutomatePage() { {hasFile && hasProcessed && originalBlobUrl && (

{renderPipelinePreview("result")}
- {processedSize != null && currentEntry?.previewKind === "image" && ( + {processedSize != null && (
{selectedFileName ?? files[0].name} @@ -575,6 +612,7 @@ export function AutomatePage() { )} + {hasProcessed && !batchZipBlob && processedUrl && ( + + )}
{/* Mobile FAB for tool palette */} @@ -877,6 +924,7 @@ export function AutomatePage() { )} + {hasProcessed && !batchZipBlob && processedUrl && ( + + )} @@ -1011,7 +1069,7 @@ export function AutomatePage() {
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3d6c1bc0..66af6481 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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", }, { diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index aa98d09a..32d648c1 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -3199,6 +3199,9 @@ export const ar: TranslationKeys = { noStepsHeading: "لا توجد خطوات بعد", searchToolsPlaceholder: "بحث في الأدوات...", step: "خطوة", + modalityWarning: "عدم توافق النوع", + modalityWarningTooltip: + "تتوقع هذه الخطوة {expected} لكنها تتلقى {received}؛ قد تفشل عند تشغيل المسار.", }, nav: { tools: "الأدوات", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 68b0befe..cfb10bb2 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -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", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 08aa092e..e8f8720b 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -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", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index e91526f5..d389cbea 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -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", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index f70fc87a..6a7a473b 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -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", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index f37745ca..f365f6f5 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -3194,6 +3194,9 @@ export const hi: TranslationKeys = { noStepsHeading: "अभी तक कोई चरण नहीं", searchToolsPlaceholder: "टूल खोजें...", step: "स्टेप", + modalityWarning: "मोडैलिटी मेल नहीं खाती", + modalityWarningTooltip: + "यह चरण {expected} की अपेक्षा करता है लेकिन {received} प्राप्त करता है; पाइपलाइन चलाने पर यह विफल हो सकता है।", }, nav: { tools: "टूल्स", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 933ffef6..cb2b4609 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -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", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index bcee781a..f0053cb4 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -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", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index a28e2edb..89b95c1f 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -3171,6 +3171,9 @@ export const ja: TranslationKeys = { noStepsHeading: "ステップがありません", searchToolsPlaceholder: "ツールを検索...", step: "ステップ", + modalityWarning: "モダリティ不一致", + modalityWarningTooltip: + "このステップは {expected} を想定していますが {received} を受け取ります。パイプラインの実行時に失敗する可能性があります。", }, nav: { tools: "ツール", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index d072935e..79d8b640 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -3154,6 +3154,9 @@ export const ko: TranslationKeys = { noStepsHeading: "단계가 없습니다", searchToolsPlaceholder: "도구 검색...", step: "단계", + modalityWarning: "모달리티 불일치", + modalityWarningTooltip: + "이 단계는 {expected}을(를) 기대하지만 {received}을(를) 받습니다. 파이프라인을 실행하면 실패할 수 있습니다.", }, nav: { tools: "도구", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 8a5b19e7..ce61f713 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -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", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 6bb24da0..d7a30e80 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -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", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 7fff990b..04c2da51 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -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", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 4f682d33..b95d1c1d 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -3223,6 +3223,9 @@ export const ru: TranslationKeys = { noStepsHeading: "Шагов пока нет", searchToolsPlaceholder: "Поиск инструментов...", step: "Шаг", + modalityWarning: "Несоответствие модальности", + modalityWarningTooltip: + "Этот шаг ожидает {expected}, но получает {received}; он может завершиться ошибкой при запуске конвейера.", }, nav: { tools: "Инструменты", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index fcffb596..0029b6a0 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -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", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 36b8ec77..55d2ad5e 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -3180,6 +3180,8 @@ export const th: TranslationKeys = { noStepsHeading: "ยังไม่มีขั้นตอน", searchToolsPlaceholder: "ค้นหาเครื่องมือ...", step: "ขั้นตอน", + modalityWarning: "ประเภทไม่ตรงกัน", + modalityWarningTooltip: "ขั้นตอนนี้คาดหวัง {expected} แต่ได้รับ {received} อาจล้มเหลวเมื่อคุณเรียกใช้ไปป์ไลน์", }, nav: { tools: "เครื่องมือ", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index c8e8d464..2b0ddcce 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -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", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 42895be4..a332d4d4 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -3225,6 +3225,9 @@ export const uk: TranslationKeys = { noStepsHeading: "Кроків поки немає", searchToolsPlaceholder: "Пошук інструментів...", step: "Крок", + modalityWarning: "Невідповідність модальності", + modalityWarningTooltip: + "Цей крок очікує {expected}, але отримує {received}; він може завершитися помилкою під час запуску конвеєра.", }, nav: { tools: "Інструменти", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 6440cac3..128fea23 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -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ụ", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index d9d4058b..aa9e6587 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -3130,6 +3130,8 @@ export const zhCN: TranslationKeys = { noStepsHeading: "暂无步骤", searchToolsPlaceholder: "搜索工具...", step: "步骤", + modalityWarning: "模态不匹配", + modalityWarningTooltip: "此步骤需要 {expected},但接收到 {received};运行管道时可能会失败。", }, nav: { tools: "工具", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 792fd69b..001b09a6 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -3129,6 +3129,8 @@ export const zhTW: TranslationKeys = { noStepsHeading: "尚無步驟", searchToolsPlaceholder: "搜尋工具...", step: "步驟", + modalityWarning: "模態不相符", + modalityWarningTooltip: "此步驟需要 {expected},但收到 {received};執行管線時可能會失敗。", }, nav: { tools: "工具", diff --git a/packages/shared/src/modality.ts b/packages/shared/src/modality.ts index 1c553edf..6e171fab 100644 --- a/packages/shared/src/modality.ts +++ b/packages/shared/src/modality.ts @@ -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; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 71312adc..6d54c36c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; diff --git a/tests/integration/platform/pipeline-multimodal.test.ts b/tests/integration/platform/pipeline-multimodal.test.ts new file mode 100644 index 00000000..9f239fea --- /dev/null +++ b/tests/integration/platform/pipeline-multimodal.test.ts @@ -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 }>, +) { + 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); +}); diff --git a/tests/unit/shared/modality-helpers.test.ts b/tests/unit/shared/modality-helpers.test.ts new file mode 100644 index 00000000..a1915fd7 --- /dev/null +++ b/tests/unit/shared/modality-helpers.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/web/pipeline-compat.test.ts b/tests/unit/web/pipeline-compat.test.ts new file mode 100644 index 00000000..e293457d --- /dev/null +++ b/tests/unit/web/pipeline-compat.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/web/pipeline-controls.test.tsx b/tests/unit/web/pipeline-controls.test.tsx new file mode 100644 index 00000000..ccaf67f1 --- /dev/null +++ b/tests/unit/web/pipeline-controls.test.tsx @@ -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(); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ format: "mp3", bitrateKbps: 192 }), + ); + }); + + it("emits the chosen format on change", async () => { + const onChange = vi.fn(); + render(); + 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(); + 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( + , + ); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ terms: [], caseSensitive: false }), + ); + }); + + it("splits comma-separated input into terms array", async () => { + const onChange = vi.fn(); + render(); + 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(); + 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(); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ kind: "bar", width: 960, height: 540 }), + ); + }); +});