mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user