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:
SnapOtter
2026-05-09 13:55:00 +08:00
parent 9f97c466b3
commit 7f131d99a6
19 changed files with 48 additions and 20 deletions
+2 -2
View File
@@ -221,8 +221,8 @@ export async function validateImageBuffer(
detectedFormat = "raw";
}
// TGA has no magic bytes — detect by extension only
if (!detectedFormat && ext === "tga") {
// TGA has no magic bytes and its header can match other formats (e.g. CUR)
if (ext === "tga") {
detectedFormat = "tga";
}
+2 -3
View File
@@ -56,9 +56,8 @@ const fontCache = new Map<string, opentype.Font>();
export function loadFont(family: string): opentype.Font {
const filename = FONT_MAP[family] ?? FONT_MAP.anton;
if (fontCache.has(filename)) {
return fontCache.get(filename)!;
}
const cached = fontCache.get(filename);
if (cached) return cached;
const buf = readFileSync(join(FONT_DIR, filename));
let font: opentype.Font;
+1 -1
View File
@@ -47,7 +47,7 @@ function serveStaticFile(
filename: string,
reply: FastifyReply,
cacheControl: string,
): FastifyReply | void {
): FastifyReply | undefined {
if (hasPathTraversal(filename)) {
return reply.status(400).send({ error: "Invalid filename" });
}
+8
View File
@@ -254,12 +254,20 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
listeners.set(jobId, new Set());
}
let ended = false;
const callback = (data: JobProgress | SingleFileProgress) => {
if (ended) return;
sendEvent(data);
if (
("status" in data && (data.status === "completed" || data.status === "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();
}
};
+4 -1
View File
@@ -260,7 +260,10 @@ export function registerMemeGenerator(app: FastifyInstance) {
// ── Process ─────────────────────────────────────────────────────
try {
// 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);
-1
View File
@@ -4,7 +4,6 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
import { Toaster } from "sonner";
import { ConnectionMonitor } from "./components/common/connection-monitor";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
import { AppLayout } from "./components/layout/app-layout";
import { useAuth } from "./hooks/use-auth";
import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics";
import { useAnalyticsStore } from "./stores/analytics-store";
@@ -75,7 +75,6 @@ export function EditorOptionsBar() {
// Eyedropper state (managed here since EyedropperOptions requires props)
const [eyedropperSampleSize, setEyedropperSampleSize] = useState<SampleSize>(1);
const [sampledColor, setSampledColor] = useState<string | null>(null);
// Transform tool API (managed here since TransformOptions requires props)
const transformApi = useTransformTool();
@@ -98,7 +97,7 @@ export function EditorOptionsBar() {
<EyedropperOptions
sampleSize={eyedropperSampleSize}
onSampleSizeChange={setEyedropperSampleSize}
sampledColor={sampledColor ?? foregroundColor}
sampledColor={foregroundColor}
/>
)}
{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);
return () => clearTimeout(timer);
}, [adjustments, filters, canvasSize]);
}, [canvasSize]);
const hasChanges = useMemo(() => {
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
@@ -7,7 +7,7 @@ import {
Workflow,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
@@ -34,6 +34,7 @@ export function usePipelineProcessor() {
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const abortRef = useRef<AbortController | null>(null);
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Clean up on unmount
@@ -43,6 +44,7 @@ export function usePipelineProcessor() {
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) eventSourceRef.current.close();
if (xhrRef.current) xhrRef.current.abort();
if (abortRef.current) abortRef.current.abort();
};
}, []);
@@ -252,10 +254,12 @@ export function usePipelineProcessor() {
formData.append("clientJobId", clientJobId);
try {
abortRef.current = new AbortController();
const response = await fetch("/api/v1/pipeline/batch", {
method: "POST",
headers: formatHeaders(),
body: formData,
signal: abortRef.current.signal,
});
if (elapsedRef.current) clearInterval(elapsedRef.current);
+4
View File
@@ -44,6 +44,7 @@ export function useToolProcessor(toolId: string) {
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const abortRef = useRef<AbortController | null>(null);
const isAiTool = AI_PYTHON_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 (eventSourceRef.current) eventSourceRef.current.close();
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);
try {
abortRef.current = new AbortController();
const response = await fetch(`/api/v1/tools/${toolId}/batch`, {
method: "POST",
headers: formatHeaders(),
body: formData,
signal: abortRef.current.signal,
});
if (elapsedRef.current) clearInterval(elapsedRef.current);
+7
View File
@@ -39,6 +39,7 @@ function revokeEntries(entries: FileEntry[]): void {
for (const entry of entries) {
URL.revokeObjectURL(entry.blobUrl);
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();
if (state.entries[i]?.file !== file) return;
const updated = [...state.entries];
const oldBlobUrl = updated[i].blobUrl;
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) };
if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl);
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
});
}
@@ -172,7 +175,9 @@ export const useFileStore = create<FileState>((set, get) => ({
const state = get();
if (state.entries[i]?.file !== file) return;
const updated = [...state.entries];
const oldBlobUrl = updated[i].blobUrl;
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) };
if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl);
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
});
}
@@ -186,6 +191,7 @@ export const useFileStore = create<FileState>((set, get) => ({
URL.revokeObjectURL(removed.blobUrl);
if (removed.processedUrl) URL.revokeObjectURL(removed.processedUrl);
if (removed.processedPreviewUrl) URL.revokeObjectURL(removed.processedPreviewUrl);
const newEntries = entries.filter((_, i) => i !== index);
let newIndex = selectedIndex;
@@ -285,6 +291,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const { entries, selectedIndex } = get();
for (const entry of entries) {
if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl);
if (entry.processedPreviewUrl) URL.revokeObjectURL(entry.processedPreviewUrl);
}
const resetEntries = entries.map((e) => ({
...e,