fix: resolve 18 QA-discovered bugs across tools, previews, and the AI pipeline (#242)

Exhaustive QA sweep of all 157 tools. Fixes: CSP blob media, csv-excel ExcelJS interop, ocr-pdf segfault, chart-maker upload, non-PDF doc preview, RAW decode, merge-tool multi-file path, html-to-image chromium, ogv/wma/amr/ac3 preview fallbacks, meme/gif/stabilize codecs, nav+home a11y. Plus orphan-format and test-debt cleanup, the AI bundle build script, and a reusable Playwright QA harness under tests/qa/.
This commit is contained in:
SnapOtter
2026-06-15 22:26:24 +08:00
committed by GitHub
parent 25babfae15
commit d8cf979d4b
83 changed files with 16014 additions and 112 deletions
+3 -3
View File
@@ -13,17 +13,17 @@ const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
* would require forking the Scalar plugin, which is not practical.
*/
export function buildCsp(isDocs: boolean): string {
const connectSrc = ["'self'", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
const connectSrc = ["'self'", "blob:", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:";
const scriptSrc = isDocs
? "'self' 'unsafe-inline' https://us-assets.i.posthog.com"
: "'self' https://us-assets.i.posthog.com";
if (isDocs) {
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`;
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`;
}
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
}
export function getSecurityHeaders(): Record<string, string> {
+7
View File
@@ -243,6 +243,13 @@ export async function validateImageBuffer(
detectedFormat = "png";
}
// RAW formats with non-TIFF magic bytes (Panasonic RW2, some Olympus ORF,
// Pentax PEF, etc.) are not caught by MAGIC_BYTES. Fall back to
// extension-based detection for known Camera RAW extensions.
if (!detectedFormat && ext && isRawExtension(ext)) {
detectedFormat = "raw";
}
if (!detectedFormat) {
return { valid: false, reason: "Unrecognized image format" };
}
+35 -1
View File
@@ -211,7 +211,41 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
// ExifTool not available or no embedded JPEG -- fall through
}
// Attempt 2: ImageMagick + LibRaw delegate (full decode)
// Attempt 1b: ExifTool PreviewImage extraction (some formats store
// preview under a different tag than JpgFromRaw).
try {
const { stdout } = await execFileAsync("exiftool", ["-b", "-PreviewImage", inputPath], {
encoding: "buffer",
maxBuffer: 50 * 1024 * 1024,
timeout: 30_000,
} as never);
const previewBuf = stdout as unknown as Buffer;
if (previewBuf && previewBuf.length > 1000) {
if (previewBuf[0] === 0xff && previewBuf[1] === 0xd8) {
return previewBuf;
}
}
} catch {
// fall through
}
// Attempt 2: dcraw_emu from libraw-bin (direct LibRaw decode to TIFF).
// More reliable than ImageMagick's delegate chain for Camera RAW.
try {
await execFileAsync("dcraw_emu", ["-T", "-w", "-o", "1", inputPath], { timeout: 120_000 });
// dcraw_emu writes output next to the input with a .tiff extension
const dcrawOutput = inputPath.replace(/\.[^.]+$/, ".tiff");
const tiffBuf = await readFile(dcrawOutput);
await rm(dcrawOutput, { force: true }).catch(() => {});
if (tiffBuf.length > 0) {
// Convert TIFF to PNG via Sharp (Sharp handles TIFF natively)
return await sharp(tiffBuf).png().toBuffer();
}
} catch {
// dcraw_emu not available or unsupported format -- fall through
}
// Attempt 3: ImageMagick + LibRaw delegate (full decode)
const cmd = await findMagickCmd();
await execFileAsync(
cmd,
+4 -2
View File
@@ -20,8 +20,10 @@ export function registerCsvExcel(app: FastifyInstance) {
const base = input.filename.replace(/\.[^.]+$/, "");
const lower = input.filename.toLowerCase();
// Dynamic import: exceljs is heavy; load it only when this tool runs
const ExcelJS = await import("exceljs");
// Dynamic import: exceljs is heavy; load it only when this tool runs.
// exceljs is CJS, so under ESM the constructor lives on the default export
// (namespace `.Workbook` is undefined -> "is not a constructor").
const { default: ExcelJS } = await import("exceljs");
if (lower.endsWith(".xlsx")) {
// xlsx -> csv: load workbook, pick the Nth worksheet, extract rows
+12 -1
View File
@@ -170,9 +170,20 @@ async function processMeme(
gif: "image/gif",
};
// Ensure the output filename extension matches the actual raster format
// (e.g. SVG input produces a PNG buffer, so the name must end in .png).
const extMap: Record<string, string> = {
jpeg: ".jpg",
png: ".png",
webp: ".webp",
gif: ".gif",
};
const correctExt = extMap[detectedFormat] ?? ".png";
const outFilename = filename.replace(/\.[^.]+$/, correctExt);
return {
buffer: result,
filename,
filename: outFilename,
contentType: mimeMap[detectedFormat] ?? "image/png",
};
}
+52 -12
View File
@@ -1,5 +1,5 @@
import { extname, join } from "node:path";
import { probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine";
import { type EncoderTarget, probeMedia, resolveEncoder, runFfmpeg } from "@snapotter/media-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { runFfmpegWithProgress, stageMediaInputs, videoContentType } from "../../lib/media-tool.js";
@@ -9,6 +9,44 @@ const settingsSchema = z.object({
smoothing: z.number().int().min(5).max(60).default(15),
});
/**
* Choose a codec that is valid for the given container extension.
* webm requires VP9 (or VP8/AV1); ogv requires Theora; everything
* else gets H.264 (mp4/mov/mkv/avi/ts).
*/
function codecForContainer(ext: string): {
target: EncoderTarget;
encodeArgs: string[];
} {
const lower = ext.toLowerCase();
if (lower === ".webm") {
return {
target: "vp9",
encodeArgs: ["-c:v", resolveEncoder("vp9"), "-crf", "30", "-b:v", "0", "-row-mt", "1"],
};
}
if (lower === ".ogv" || lower === ".ogg") {
// Theora has no HW-accel path; use libtheora directly.
return {
target: "h264", // unused, just for the type
encodeArgs: ["-c:v", "libtheora", "-q:v", "7"],
};
}
return {
target: "h264",
encodeArgs: [
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
],
};
}
export function registerStabilizeVideo(app: FastifyInstance) {
createToolRoute(app, {
toolId: "stabilize-video",
@@ -38,9 +76,19 @@ export function registerStabilizeVideo(app: FastifyInstance) {
},
);
// Pass 2: stabilization with re-encode
// Pass 2: stabilization with re-encode using a container-appropriate codec.
ctx.report(50, "Stabilizing");
const outPath = join(ctx.scratchDir, "media", outName);
const { encodeArgs } = codecForContainer(origExt);
// Audio: webm/ogv need Opus/Vorbis; for other containers just copy.
const audioArgs =
origExt.toLowerCase() === ".webm"
? ["-c:a", resolveEncoder("opus")]
: origExt.toLowerCase() === ".ogv" || origExt.toLowerCase() === ".ogg"
? ["-c:a", "libvorbis"]
: ["-c:a", "copy"];
await runFfmpegWithProgress(
ctx,
[
@@ -48,16 +96,8 @@ export function registerStabilizeVideo(app: FastifyInstance) {
inPath,
"-vf",
`vidstabtransform=input=${trf}:smoothing=${settings.smoothing}`,
"-c:v",
resolveEncoder("h264"),
"-crf",
"20",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
...encodeArgs,
...audioArgs,
outPath,
],
info.durationS,
+8 -2
View File
@@ -23,13 +23,19 @@ export function registerVideoToGif(app: FastifyInstance) {
const outName = `${base}.gif`;
const { outPath } = await runMediaTool(ctx, outName, (inPath, out) => {
// Place -ss/-t AFTER -i (output-side seeking) so ffmpeg decodes
// from the start. Input-side seeking relies on a keyframe index
// which FLV (and some other legacy containers) often lack, causing
// zero decoded frames and exit 234.
return [
"-fflags",
"+genpts",
"-i",
inPath,
"-ss",
String(settings.startS),
"-t",
String(settings.durationS),
"-i",
inPath,
"-vf",
`fps=${settings.fps},scale=${settings.width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
out,
@@ -1,6 +1,7 @@
import { Pause, Play } from "lucide-react";
import { Download, Pause, Play, Volume2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import WaveSurfer from "wavesurfer.js";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
interface WaveformPlayerProps {
@@ -14,13 +15,19 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`;
}
/** Timeout (ms) after which we assume WaveSurfer cannot decode the audio. */
const DECODE_TIMEOUT_MS = 15_000;
export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isReady, setIsReady] = useState(false);
const [decodeError, setDecodeError] = useState(false);
const readyRef = useRef(false);
// Detect dark mode from the document class (toggled by useTheme)
const isDark =
@@ -31,6 +38,9 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
useEffect(() => {
if (!containerRef.current) return;
readyRef.current = false;
setDecodeError(false);
const ws = WaveSurfer.create({
container: containerRef.current,
waveColor,
@@ -48,10 +58,19 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
ws.load(src);
ws.on("ready", () => {
readyRef.current = true;
setDuration(ws.getDuration());
setIsReady(true);
});
// F8: surface decode errors for formats WaveSurfer cannot handle
// (wma, amr, ac3, etc.)
ws.on("error", () => {
if (!readyRef.current) {
setDecodeError(true);
}
});
ws.on("audioprocess", () => {
setCurrentTime(ws.getCurrentTime());
});
@@ -72,9 +91,19 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
setIsPlaying(false);
});
// Decode timeout: if WaveSurfer hasn't fired "ready" after a generous
// window, the format is likely unsupported.
const timer = setTimeout(() => {
if (!readyRef.current) {
setDecodeError(true);
}
}, DECODE_TIMEOUT_MS);
return () => {
clearTimeout(timer);
ws.destroy();
wsRef.current = null;
readyRef.current = false;
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
@@ -86,6 +115,30 @@ export function WaveformPlayer({ src, className }: WaveformPlayerProps) {
wsRef.current?.playPause();
}, []);
// F8: graceful fallback when the browser cannot decode the audio
if (decodeError) {
return (
<div className={cn("w-full max-w-2xl mx-auto", className)}>
<div className="rounded-lg border border-border bg-background p-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<Volume2 className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{t.toolPage.audioDecodeUnsupported}</p>
<a
href={src}
download
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
>
<Download className="h-4 w-4" />
{t.common.download}
</a>
</div>
</div>
</div>
);
}
return (
<div className={cn("w-full max-w-2xl mx-auto", className)}>
<div className="rounded-lg border border-border bg-background p-4">
+2 -2
View File
@@ -95,7 +95,7 @@ export function TopNav({
</span>
</div>
) : (
<Link to="/" className="shrink-0">
<Link to="/" className="shrink-0" aria-label={t.a11y.homeLink}>
<OtterLogo className="h-7 w-7" />
</Link>
)}
@@ -128,7 +128,7 @@ export function TopNav({
)}
>
{/* Left: Logo */}
<Link to="/" className="shrink-0 me-4">
<Link to="/" className="shrink-0 me-4" aria-label={t.a11y.homeLink}>
<OtterLogo className="h-7 w-7" />
</Link>
@@ -17,7 +17,7 @@ const INPUT_CLASS =
export function ChartMakerSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
const { processFiles, processing, error, downloadUrl, progress } =
useToolProcessor("chart-maker");
const [kind, setKind] = useState("bar");
@@ -28,11 +28,7 @@ export function ChartMakerSettings() {
const handleProcess = () => {
const settings: Record<string, unknown> = { kind, width, height };
if (title.trim()) settings.title = title.trim();
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
processFiles(files, settings);
};
const hasFile = files.length > 0;
@@ -138,9 +134,7 @@ export function ChartMakerSettings() {
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1
? t.toolSettings["chart-maker"].submitBatch.replace("{count}", String(files.length))
: t.toolSettings["chart-maker"].submit}
{t.toolSettings["chart-maker"].submit}
</button>
)}
@@ -1,26 +1,18 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function CreateZipSettings() {
const { t } = useTranslation();
const s = t.toolSettings["create-zip"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("create-zip");
const { processFiles, processing, error, progress } = useToolProcessor("create-zip");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
processFiles(files, {});
};
return (
@@ -44,7 +36,7 @@ export function CreateZipSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
{s.submit}
</button>
)}
</div>
@@ -1,3 +1,4 @@
import { FileText } from "lucide-react";
import * as pdfjs from "pdfjs-dist";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
@@ -16,8 +17,14 @@ export function DocumentView() {
const [page, setPage] = useState(1);
const [pageCount, setPageCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const hasProcessedUrl = !!entry?.processedUrl;
const src = entry?.processedUrl ?? entry?.blobUrl;
// F6: detect non-PDF input files (word, excel, html, markdown, etc.)
const isPdfInput = entry?.file?.name?.toLowerCase().endsWith(".pdf") ?? false;
const showInputFallback = !isPdfInput && !hasProcessedUrl;
/* A+B: reset pagination and clear stale errors when the document changes.
src is intentionally a trigger-only dep (not read inside the callback). */
// biome-ignore lint/correctness/useExhaustiveDependencies: src is the trigger
@@ -29,8 +36,10 @@ export function DocumentView() {
/* C+D: cancel in-flight renders and destroy the doc proxy on cleanup. */
useEffect(() => {
if (!canvasRef.current) return;
const file = entry?.file;
if (!canvasRef.current || showInputFallback) return;
// F22: when processedUrl is available, use URL-based loading instead of
// entry.file (which is the original non-PDF input and would fail pdf.js)
const file = hasProcessedUrl ? undefined : entry?.file;
if (!file && !src) return;
let cancelled = false;
let doc: pdfjs.PDFDocumentProxy | undefined;
@@ -63,10 +72,26 @@ export function DocumentView() {
renderTask?.cancel();
doc?.loadingTask.destroy();
};
}, [src, page, entry?.file]);
}, [src, page, entry?.file, showInputFallback, hasProcessedUrl]);
if (!entry) return null;
// F6: graceful fallback for non-PDF input files
if (showInputFallback) {
const ext = entry.file?.name?.split(".").pop()?.toUpperCase() ?? "";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4">
<div className="mx-auto w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-2">
<FileText className="h-8 w-8 text-muted-foreground" />
</div>
{ext && <p className="text-xs text-muted-foreground">{ext}</p>}
<p className="text-sm text-muted-foreground text-center">
{t.tools.documentView.inputNotPreviewable}
</p>
</div>
);
}
return (
<div className="flex h-full w-full flex-col items-center gap-2 overflow-auto p-4">
{error && <p className="p-4 text-sm text-destructive">{t.tools.documentView.loadFailed}</p>}
@@ -1,16 +1,40 @@
import { useRef, useState } from "react";
import { NonNativePreview } from "@/components/common/non-native-preview";
import { useTranslation } from "@/contexts/i18n-context";
import { useFileStore } from "@/stores/file-store";
/**
* Native <video>/<audio> playback over the Range-capable download endpoint
* (spec 4.6). Shows the processed result when present, else the source file.
* Falls back to NonNativePreview (server transcode) when the browser cannot
* decode the codec (e.g. Theora in .ogv -- videoWidth is 0).
*/
export function MediaPlayerView() {
const { t } = useTranslation();
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
const videoRef = useRef<HTMLVideoElement>(null);
const [unsupportedCodec, setUnsupportedCodec] = useState(false);
if (!entry) return null;
const src = entry.processedUrl ?? entry.blobUrl;
const isAudio = entry.modality === "audio";
// F7: if the browser loaded the container but cannot decode the codec,
// videoWidth will be 0. Fall back to the server-transcode preview.
if (!isAudio && unsupportedCodec) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<NonNativePreview
file={entry.file}
src={src}
filename={entry.file?.name ?? "video"}
fileSize={entry.file?.size ?? 0}
modality="video"
/>
</div>
);
}
return (
<div className="flex h-full w-full items-center justify-center p-4">
{isAudio ? (
@@ -19,10 +43,16 @@ export function MediaPlayerView() {
</audio>
) : (
<video
ref={videoRef}
controls
src={src}
className="max-h-full max-w-full rounded-lg"
data-testid="media-player-video"
onLoadedMetadata={() => {
if (videoRef.current && videoRef.current.videoWidth === 0) {
setUnsupportedCodec(true);
}
}}
>
<track kind="captions" />
{t.tools.mediaPlayer.unsupported}
@@ -2,7 +2,6 @@ import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type AudioFormat = "mp3" | "wav" | "flac" | "m4a";
@@ -11,21 +10,14 @@ export function MergeAudioSettings() {
const { t } = useTranslation();
const s = t.toolSettings["merge-audio"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("merge-audio");
const { processFiles, processing, error, progress } = useToolProcessor("merge-audio");
const [outFormat, setOutFormat] = useState<AudioFormat>("mp3");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const hasEnough = files.length >= 2;
const handleProcess = () => {
const settings = { format: outFormat };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
processFiles(files, { format: outFormat });
};
return (
@@ -65,10 +57,10 @@ export function MergeAudioSettings() {
type="button"
data-testid="merge-audio-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
{s.submit}
</button>
)}
</div>
@@ -1,26 +1,18 @@
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function MergeCsvsSettings() {
const { t } = useTranslation();
const s = t.toolSettings["merge-csvs"];
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("merge-csvs");
const { processFiles, processing, error, progress } = useToolProcessor("merge-csvs");
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const hasEnough = files.length >= 2;
const handleProcess = () => {
const settings = {};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
processFiles(files, {});
};
return (
@@ -41,10 +33,10 @@ export function MergeCsvsSettings() {
type="button"
data-testid="merge-csvs-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
disabled={!hasEnough || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
{s.submit}
</button>
)}
</div>
+1 -1
View File
@@ -193,7 +193,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
"to-epub": "no-comparison",
// Data tools
"chart-maker": "no-dropzone",
"chart-maker": "no-comparison",
"csv-excel": "no-comparison",
"csv-json": "no-comparison",
"json-xml": "no-comparison",
+1
View File
@@ -119,6 +119,7 @@ export function HomePage() {
return (
<AppLayout>
<div>
<h1 className="sr-only">{t.homePage.heading}</h1>
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<HomeSearchBar
value={search}
+16 -8
View File
@@ -366,21 +366,27 @@ export function ToolPage() {
setMobileSettingsOpen(false);
}, [toolId]);
const toolAccept = registryEntry?.accept ?? tool?.acceptedInputs?.join(",");
const toolAccept = registryEntry?.accept ?? (tool?.acceptedInputs?.join(",") || undefined);
const acceptsAnyFile = !registryEntry?.accept && tool?.acceptedInputs?.length === 0;
const toolAcceptExts = useMemo(
() => toolAccept?.split(",").map((e) => e.trim().replace(/^\./, "").toLowerCase()),
() =>
toolAccept
?.split(",")
.map((e) => e.trim().replace(/^\./, "").toLowerCase())
.filter(Boolean),
[toolAccept],
);
const toolFileFilter = useMemo(() => {
if (acceptsAnyFile) return () => true;
if (!toolAcceptExts || toolAcceptExts.length === 0) return undefined;
return (file: File) => {
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
return toolAcceptExts.includes(ext);
};
}, [toolAcceptExts]);
}, [toolAcceptExts, acceptsAnyFile]);
const toolAcceptDescription = useMemo(
() =>
toolAcceptExts
toolAcceptExts && toolAcceptExts.length > 0
? `${toolAcceptExts.map((e) => e.toUpperCase()).join(", ")} files only`
: undefined,
[toolAcceptExts],
@@ -415,16 +421,18 @@ export function ToolPage() {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept =
toolAccept ??
"image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm";
if (!acceptsAnyFile) {
input.accept =
toolAccept ??
"image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm";
}
input.onchange = (e) => {
const selected = Array.from((e.target as HTMLInputElement).files || []);
const newFiles = toolFileFilter ? selected.filter(toolFileFilter) : selected;
if (newFiles.length > 0) addFiles(newFiles);
};
input.click();
}, [addFiles, toolAccept, toolFileFilter]);
}, [addFiles, toolAccept, toolFileFilter, acceptsAnyFile]);
// Page-level drag handlers (active when a file is already loaded)
const handleDragEnter = useCallback((e: React.DragEvent) => {