mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: QA sweep -- SSE crash, memory leaks, HEIC Docker decode, TGA detection, lint cleanup
- Fix SSE write-after-end crash in progress.ts (remove callback before ending stream) - Fix blob URL memory leaks: revoke processedPreviewUrl and old HEIC preview URLs - Add AbortController to batch fetch in use-tool-processor and use-pipeline-processor - Fix TGA format misidentified as CUR (extension overrides magic bytes) - Add libheif-plugin-libde265 to Docker for HEIC/HEIF decode support - Remove unused imports and state (AppLayout, setSampledColor, useEffect) - Fix non-null assertions in meme-text-renderer and meme-generator - Fix confusing void type in meme-templates - Remove unnecessary useEffect deps in adjustments-panel - Fix Playwright strict mode violations in 5 E2E tests
This commit is contained in:
@@ -221,8 +221,8 @@ export async function validateImageBuffer(
|
|||||||
detectedFormat = "raw";
|
detectedFormat = "raw";
|
||||||
}
|
}
|
||||||
|
|
||||||
// TGA has no magic bytes — detect by extension only
|
// TGA has no magic bytes and its header can match other formats (e.g. CUR)
|
||||||
if (!detectedFormat && ext === "tga") {
|
if (ext === "tga") {
|
||||||
detectedFormat = "tga";
|
detectedFormat = "tga";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ const fontCache = new Map<string, opentype.Font>();
|
|||||||
export function loadFont(family: string): opentype.Font {
|
export function loadFont(family: string): opentype.Font {
|
||||||
const filename = FONT_MAP[family] ?? FONT_MAP.anton;
|
const filename = FONT_MAP[family] ?? FONT_MAP.anton;
|
||||||
|
|
||||||
if (fontCache.has(filename)) {
|
const cached = fontCache.get(filename);
|
||||||
return fontCache.get(filename)!;
|
if (cached) return cached;
|
||||||
}
|
|
||||||
|
|
||||||
const buf = readFileSync(join(FONT_DIR, filename));
|
const buf = readFileSync(join(FONT_DIR, filename));
|
||||||
let font: opentype.Font;
|
let font: opentype.Font;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function serveStaticFile(
|
|||||||
filename: string,
|
filename: string,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
cacheControl: string,
|
cacheControl: string,
|
||||||
): FastifyReply | void {
|
): FastifyReply | undefined {
|
||||||
if (hasPathTraversal(filename)) {
|
if (hasPathTraversal(filename)) {
|
||||||
return reply.status(400).send({ error: "Invalid filename" });
|
return reply.status(400).send({ error: "Invalid filename" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,12 +254,20 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
|
|||||||
listeners.set(jobId, new Set());
|
listeners.set(jobId, new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ended = false;
|
||||||
const callback = (data: JobProgress | SingleFileProgress) => {
|
const callback = (data: JobProgress | SingleFileProgress) => {
|
||||||
|
if (ended) return;
|
||||||
sendEvent(data);
|
sendEvent(data);
|
||||||
if (
|
if (
|
||||||
("status" in data && (data.status === "completed" || data.status === "failed")) ||
|
("status" in data && (data.status === "completed" || data.status === "failed")) ||
|
||||||
("phase" in data && (data.phase === "complete" || data.phase === "failed"))
|
("phase" in data && (data.phase === "complete" || data.phase === "failed"))
|
||||||
) {
|
) {
|
||||||
|
ended = true;
|
||||||
|
const subs = listeners.get(jobId);
|
||||||
|
if (subs) {
|
||||||
|
subs.delete(callback);
|
||||||
|
if (subs.size === 0) listeners.delete(jobId);
|
||||||
|
}
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ export function registerMemeGenerator(app: FastifyInstance) {
|
|||||||
// ── Process ─────────────────────────────────────────────────────
|
// ── Process ─────────────────────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
// Normalize the image for Sharp compatibility
|
// Normalize the image for Sharp compatibility
|
||||||
imageBuffer = await autoOrient(await ensureSharpCompat(imageBuffer!));
|
if (!imageBuffer) {
|
||||||
|
return reply.status(400).send({ error: "No image provided" });
|
||||||
|
}
|
||||||
|
imageBuffer = await autoOrient(await ensureSharpCompat(imageBuffer));
|
||||||
|
|
||||||
const output = await processMeme(imageBuffer, settings, filename, templateTextBoxes);
|
const output = await processMeme(imageBuffer, settings, filename, templateTextBoxes);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
|
|||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
||||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||||
import { AppLayout } from "./components/layout/app-layout";
|
|
||||||
import { useAuth } from "./hooks/use-auth";
|
import { useAuth } from "./hooks/use-auth";
|
||||||
import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics";
|
import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics";
|
||||||
import { useAnalyticsStore } from "./stores/analytics-store";
|
import { useAnalyticsStore } from "./stores/analytics-store";
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export function EditorOptionsBar() {
|
|||||||
|
|
||||||
// Eyedropper state (managed here since EyedropperOptions requires props)
|
// Eyedropper state (managed here since EyedropperOptions requires props)
|
||||||
const [eyedropperSampleSize, setEyedropperSampleSize] = useState<SampleSize>(1);
|
const [eyedropperSampleSize, setEyedropperSampleSize] = useState<SampleSize>(1);
|
||||||
const [sampledColor, setSampledColor] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Transform tool API (managed here since TransformOptions requires props)
|
// Transform tool API (managed here since TransformOptions requires props)
|
||||||
const transformApi = useTransformTool();
|
const transformApi = useTransformTool();
|
||||||
@@ -98,7 +97,7 @@ export function EditorOptionsBar() {
|
|||||||
<EyedropperOptions
|
<EyedropperOptions
|
||||||
sampleSize={eyedropperSampleSize}
|
sampleSize={eyedropperSampleSize}
|
||||||
onSampleSizeChange={setEyedropperSampleSize}
|
onSampleSizeChange={setEyedropperSampleSize}
|
||||||
sampledColor={sampledColor ?? foregroundColor}
|
sampledColor={foregroundColor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTool === "transform" && <TransformOptions api={transformApi} />}
|
{activeTool === "transform" && <TransformOptions api={transformApi} />}
|
||||||
|
|||||||
@@ -1062,10 +1062,9 @@ export function AdjustmentsPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture on mount and when adjustments/filters change
|
|
||||||
const timer = setTimeout(captureImageData, 100);
|
const timer = setTimeout(captureImageData, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [adjustments, filters, canvasSize]);
|
}, [canvasSize]);
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
|
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function usePipelineProcessor() {
|
|||||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Clean up on unmount
|
// Clean up on unmount
|
||||||
@@ -43,6 +44,7 @@ export function usePipelineProcessor() {
|
|||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||||
if (xhrRef.current) xhrRef.current.abort();
|
if (xhrRef.current) xhrRef.current.abort();
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -252,10 +254,12 @@ export function usePipelineProcessor() {
|
|||||||
formData.append("clientJobId", clientJobId);
|
formData.append("clientJobId", clientJobId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
abortRef.current = new AbortController();
|
||||||
const response = await fetch("/api/v1/pipeline/batch", {
|
const response = await fetch("/api/v1/pipeline/batch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
|
signal: abortRef.current.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
||||||
const isMediumTool = MEDIUM_TOOLS.has(toolId);
|
const isMediumTool = MEDIUM_TOOLS.has(toolId);
|
||||||
@@ -57,6 +58,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||||
if (xhrRef.current) xhrRef.current.abort();
|
if (xhrRef.current) xhrRef.current.abort();
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -389,10 +391,12 @@ export function useToolProcessor(toolId: string) {
|
|||||||
formData.append("clientJobId", clientJobId);
|
formData.append("clientJobId", clientJobId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
abortRef.current = new AbortController();
|
||||||
const response = await fetch(`/api/v1/tools/${toolId}/batch`, {
|
const response = await fetch(`/api/v1/tools/${toolId}/batch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: formatHeaders(),
|
headers: formatHeaders(),
|
||||||
body: formData,
|
body: formData,
|
||||||
|
signal: abortRef.current.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function revokeEntries(entries: FileEntry[]): void {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
URL.revokeObjectURL(entry.blobUrl);
|
URL.revokeObjectURL(entry.blobUrl);
|
||||||
if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl);
|
if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl);
|
||||||
|
if (entry.processedPreviewUrl) URL.revokeObjectURL(entry.processedPreviewUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +151,9 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
if (state.entries[i]?.file !== file) return;
|
if (state.entries[i]?.file !== file) return;
|
||||||
const updated = [...state.entries];
|
const updated = [...state.entries];
|
||||||
|
const oldBlobUrl = updated[i].blobUrl;
|
||||||
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) };
|
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) };
|
||||||
|
if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl);
|
||||||
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
|
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,7 +175,9 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
if (state.entries[i]?.file !== file) return;
|
if (state.entries[i]?.file !== file) return;
|
||||||
const updated = [...state.entries];
|
const updated = [...state.entries];
|
||||||
|
const oldBlobUrl = updated[i].blobUrl;
|
||||||
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) };
|
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) };
|
||||||
|
if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl);
|
||||||
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
|
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -186,6 +191,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
|
|
||||||
URL.revokeObjectURL(removed.blobUrl);
|
URL.revokeObjectURL(removed.blobUrl);
|
||||||
if (removed.processedUrl) URL.revokeObjectURL(removed.processedUrl);
|
if (removed.processedUrl) URL.revokeObjectURL(removed.processedUrl);
|
||||||
|
if (removed.processedPreviewUrl) URL.revokeObjectURL(removed.processedPreviewUrl);
|
||||||
|
|
||||||
const newEntries = entries.filter((_, i) => i !== index);
|
const newEntries = entries.filter((_, i) => i !== index);
|
||||||
let newIndex = selectedIndex;
|
let newIndex = selectedIndex;
|
||||||
@@ -285,6 +291,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
const { entries, selectedIndex } = get();
|
const { entries, selectedIndex } = get();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl);
|
if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl);
|
||||||
|
if (entry.processedPreviewUrl) URL.revokeObjectURL(entry.processedPreviewUrl);
|
||||||
}
|
}
|
||||||
const resetEntries = entries.map((e) => ({
|
const resetEntries = entries.map((e) => ({
|
||||||
...e,
|
...e,
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $(
|
|||||||
elif apt-cache show libmagickcore-6.q16-6-extra >/dev/null 2>&1; then \
|
elif apt-cache show libmagickcore-6.q16-6-extra >/dev/null 2>&1; then \
|
||||||
apt-get install -y --no-install-recommends libmagickcore-6.q16-6-extra; \
|
apt-get install -y --no-install-recommends libmagickcore-6.q16-6-extra; \
|
||||||
fi \
|
fi \
|
||||||
|
&& if apt-cache show libheif-plugin-libde265 >/dev/null 2>&1; then \
|
||||||
|
apt-get install -y --no-install-recommends libheif-plugin-libde265; \
|
||||||
|
fi \
|
||||||
&& if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \
|
&& if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \
|
||||||
apt-get install -y --no-install-recommends libheif-plugin-x265; \
|
apt-get install -y --no-install-recommends libheif-plugin-x265; \
|
||||||
fi \
|
fi \
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libimage-exiftool-perl \
|
libimage-exiftool-perl \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
libraw-dev \
|
libraw-dev \
|
||||||
|
&& if apt-cache show libheif-plugin-libde265 >/dev/null 2>&1; then \
|
||||||
|
apt-get install -y --no-install-recommends libheif-plugin-libde265; \
|
||||||
|
fi \
|
||||||
&& if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \
|
&& if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \
|
||||||
apt-get install -y --no-install-recommends libheif-plugin-x265; \
|
apt-get install -y --no-install-recommends libheif-plugin-x265; \
|
||||||
fi \
|
fi \
|
||||||
|
|||||||
@@ -554,8 +554,8 @@ test.describe("File Upload Preview Timing", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByText(/test-image/i)
|
.getByText(/test-image/i)
|
||||||
.first()
|
.or(page.locator("img[src^='blob:']"))
|
||||||
.or(page.locator("img[src^='blob:']").first()),
|
.first(),
|
||||||
).toBeVisible({ timeout: 5_000 });
|
).toBeVisible({ timeout: 5_000 });
|
||||||
const previewTime = Date.now() - start;
|
const previewTime = Date.now() - start;
|
||||||
|
|
||||||
|
|||||||
@@ -1078,8 +1078,8 @@ test.describe("GUI AI Tools", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByText(/upload/i)
|
.getByText(/upload/i)
|
||||||
.first()
|
.or(page.locator("img"))
|
||||||
.or(page.locator("img").first()),
|
.first(),
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ test.describe("GUI Layout Tools", () => {
|
|||||||
test("shows Background section with tabs", async ({ loggedInPage: page }) => {
|
test("shows Background section with tabs", async ({ loggedInPage: page }) => {
|
||||||
await page.goto("/beautify");
|
await page.goto("/beautify");
|
||||||
|
|
||||||
await expect(page.getByText("Background")).toBeVisible();
|
await expect(page.getByText("Background").first()).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Gradient" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Gradient" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Solid" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Solid" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Image" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Image" })).toBeVisible();
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ test.describe("GUI Watermark & Overlay Tools", () => {
|
|||||||
test("shows overlay image upload button", async ({ loggedInPage: page }) => {
|
test("shows overlay image upload button", async ({ loggedInPage: page }) => {
|
||||||
await page.goto("/compose");
|
await page.goto("/compose");
|
||||||
|
|
||||||
await expect(page.getByText("Overlay Image")).toBeVisible();
|
await expect(page.getByText("Overlay Image").first()).toBeVisible();
|
||||||
await expect(page.getByText("Choose overlay image")).toBeVisible();
|
await expect(page.getByText("Choose overlay image")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test.describe("GUI Utility Tools", () => {
|
|||||||
test("shows second image upload button with correct label", async ({ loggedInPage: page }) => {
|
test("shows second image upload button with correct label", async ({ loggedInPage: page }) => {
|
||||||
await page.goto("/compare");
|
await page.goto("/compare");
|
||||||
|
|
||||||
await expect(page.getByText("Second Image")).toBeVisible();
|
await expect(page.getByText("Second Image").first()).toBeVisible();
|
||||||
await expect(page.getByText("Choose second image")).toBeVisible();
|
await expect(page.getByText("Choose second image")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user