fix: PDF tool QA sweep - library auto-save versioning, AI fileId threading, modality polish (#251)

* fix(pdf): never enlarge on compress, honor redact case, hide same-format convert

- compress-pdf: guard both modes so output is never larger than the input; low-DPI scans could be upsampled and grow. Falls back to the original bytes.

- doc_redact.py: caseSensitive=true now filters PyMuPDF's case-insensitive search to exact-case hits, so the toggle works instead of always over-redacting.

- convert-{document,presentation,spreadsheet}: omit the input's own format from the output dropdown; the backend already rejects same-format conversions.

Verified end-to-end against an isolated Docker stack during a full visual QA sweep of all 37 PDF tools.

* fix(ui): show real multi-file preview thumbnails per modality

The bottom multi-file preview strip rendered a raw <img src=blobUrl> for every file, so audio/video/PDF inputs showed a broken-image icon plus the filename. ThumbnailStrip now branches on FileEntry.previewKind: images use <img> (icon fallback on error), video shows a captured first frame, PDF shows a pdf.js page-1 render, and audio/other show a type icon + extension. Fixes the multi-file preview across all modalities.

Verified in the browser for image/PDF/audio/video.

* fix(modality): make pipeline, batch validation, save/upload, previews & UI modality-aware

The app grew up image-only; several paths still assumed image. They now dispatch on the tool/file modality (image/video/audio/document/file):

- pipeline /execute + /batch: validate+decode input via inputHandlerFor(modality) instead of validateImageBuffer, so PDF/audio/video/data pipelines work (were rejected 'Invalid image').

- batch: non-image inputs now get per-modality validation (ffprobe/qpdf) before the worker instead of passing through unchecked.

- files /upload, user-files /save-result + /thumbnail: accept non-image files (MIME from extension; video-poster / pdf-first-page thumbnails).

- postprocess CONTENT_TYPE_TO_EXT: cover video/audio/pdf/text/zip so output extensions are corrected for all modalities.

- worker pipeline-finalize: attach result payload to the complete SSE event so the sync-window-timeout fallback still delivers a download.

- frontend: batch-ZIP blob MIME by extension (not svg-only); modality-neutral fallback labels/filenames; 'smaller file' not 'smaller image'.

Found via a codebase-wide image-only-assumption audit. Verified: PDF/audio/video pipelines + batch now work; image paths unchanged. canBrowserPreview kept image-only by design (non-image is rendered by dedicated displayMode viewers).

* fix(pipeline): generate a modality-aware preview for pipeline results

processPipelineFinalize now derives the output content type from its extension and runs generatePreview (video poster / pdf first page / image thumb), sets previewRef on the result, and surfaces previewUrl in the /execute sync response and the SSE complete event (via buildLegacyResultPayload). Pipeline outputs get a preview like single-tool results instead of always returning previewUrl: undefined.

Verified: PDF pipeline -> previewUrl returns a valid PNG first-page render; png pipeline correctly has no previewUrl; audio/video/multi-step pipelines all 200.

* fix(worker): auto-save a new library version when processing a library file

The worker hardcoded savedFileId = undefined ('No auto-save') even though the whole versioning feature was wired around it: the frontend sends fileId for library files and reads result.savedFileId, tool-factory threads fileId into ToolJobData, and autoSaveToLibrary implements the new-version save -- but the worker never called it (dead code from the tool-first-workflow merge). processToolJob now calls autoSaveToLibrary with data.fileId; without a fileId it is a no-op, so tool-first uploads are unchanged.

Verified: processing a library PDF with fileId creates version 2 (parent linked, toolChain appended, savedFileId returned); processing without fileId saves nothing.

* fix(library): ownership check + modality-aware dimensions in autoSaveToLibrary

- Only create a new version when the requester owns the parent (parent.userId === opts.userId); prevents versioning another user's file via a known fileId.

- Dimensions are modality-aware: sharp for images, ffprobe (probeMedia) for video, null for audio/document. Previously sharp-only, so non-image versions always got null dims.

* fix(ai): thread fileId + real userId through the 16 AI tool routes

AI custom routes parsed neither the fileId multipart field nor the authenticated user (they hardcoded userId: null), so processing a library file via an AI tool never created a new version, and AI jobs were unattributed. Each route now parses fileId like clientJobId and passes getAuthUser(request)?.id as userId to enqueueToolJob.

Verified: ocr-pdf on a library PDF creates a new version (v2); the ownership check still denies cross-user versioning.
This commit is contained in:
SnapOtter
2026-06-16 15:48:07 +08:00
committed by GitHub
parent d50e8e42a7
commit 08961fcc89
32 changed files with 580 additions and 131 deletions
+43 -2
View File
@@ -7,7 +7,10 @@
* keeps its own workspace-based preview write until Task 8 converts it. * keeps its own workspace-based preview write until Task 8 converts it.
*/ */
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { extname } from "node:path"; import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import sharp from "sharp"; import sharp from "sharp";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
@@ -39,6 +42,23 @@ export const CONTENT_TYPE_TO_EXT: Record<string, string> = {
"image/vnd.ms-dds": ".dds", "image/vnd.ms-dds": ".dds",
"image/x-dpx": ".dpx", "image/x-dpx": ".dpx",
"image/fits": ".fits", "image/fits": ".fits",
// Video
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
// Audio
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
"audio/flac": ".flac",
"audio/ogg": ".ogg",
"audio/aac": ".aac",
// Document / data
"application/pdf": ".pdf",
"text/plain": ".txt",
"text/csv": ".csv",
"application/json": ".json",
"application/xml": ".xml",
"application/zip": ".zip",
}; };
// ── Build output filename ────────────────────────────────────── // ── Build output filename ──────────────────────────────────────
@@ -177,15 +197,20 @@ export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise<string | un
.from(schema.userFiles) .from(schema.userFiles)
.where(eq(schema.userFiles.id, opts.fileId)); .where(eq(schema.userFiles.id, opts.fileId));
if (!parent) return undefined; if (!parent) return undefined;
// Only version a file the requester owns; never create a version on
// another user's file via a known fileId.
if (parent.userId !== opts.userId) return undefined;
const newVersion = parent.version + 1; const newVersion = parent.version + 1;
const parentChain: string[] = parent.toolChain ?? []; const parentChain: string[] = parent.toolChain ?? [];
const newToolChain = [...parentChain, opts.toolId]; const newToolChain = [...parentChain, opts.toolId];
const storedName = await saveFile(opts.buffer, opts.outName); const storedName = await saveFile(opts.buffer, opts.outName);
// Get image dimensions from the processed output // Output dimensions: sharp for images, ffprobe for video; null where N/A
// (audio/document have no pixel dimensions). Non-critical, best-effort.
let width: number | null = null; let width: number | null = null;
let height: number | null = null; let height: number | null = null;
if (opts.contentType.startsWith("image/")) {
try { try {
const meta = await sharp(opts.buffer).metadata(); const meta = await sharp(opts.buffer).metadata();
width = meta.width ?? null; width = meta.width ?? null;
@@ -193,6 +218,22 @@ export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise<string | un
} catch { } catch {
// dimensions are non-critical // dimensions are non-critical
} }
} else if (opts.contentType.startsWith("video/")) {
const probeDir = join(tmpdir(), `autosave-probe-${randomUUID()}`);
try {
await mkdir(probeDir, { recursive: true });
const probePath = join(probeDir, "input");
await writeFile(probePath, opts.buffer);
const info = await probeMedia(probePath);
const v = info.streams.find((s) => s.type === "video");
width = v?.width ?? null;
height = v?.height ?? null;
} catch {
// dimensions are non-critical
} finally {
await rm(probeDir, { recursive: true, force: true }).catch(() => {});
}
}
const newId = randomUUID(); const newId = randomUUID();
await db.insert(schema.userFiles).values({ await db.insert(schema.userFiles).values({
+67 -14
View File
@@ -44,7 +44,7 @@ import { hasAiJobHandler, runAiToolJob } from "./ai-handlers.js";
import { recordChildOutcome } from "./batch-progress.js"; import { recordChildOutcome } from "./batch-progress.js";
import { registerCancelable, unregisterCancelable } from "./cancel.js"; import { registerCancelable, unregisterCancelable } from "./cancel.js";
import { createBullMQConnection } from "./connection.js"; import { createBullMQConnection } from "./connection.js";
import { buildOutputName, generatePreview } from "./postprocess.js"; import { autoSaveToLibrary, buildOutputName, generatePreview } from "./postprocess.js";
import { runSystemJob } from "./system-jobs.js"; import { runSystemJob } from "./system-jobs.js";
import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js";
@@ -267,8 +267,18 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
// Generate preview for non-browser-previewable formats // Generate preview for non-browser-previewable formats
const previewRef = await generatePreview(resultBuffer, resultContentType, jobId, inputBuffer); const previewRef = await generatePreview(resultBuffer, resultContentType, jobId, inputBuffer);
// No auto-save -- users save to library explicitly via the UI // Auto-save a new version when the input came from the user's library
const savedFileId: string | undefined = undefined; // (data.fileId is set by tool-factory when the upload referenced a
// library file). Without a fileId this is a no-op, so tool-first uploads
// are not auto-saved.
const savedFileId = await autoSaveToLibrary({
fileId: data.fileId,
userId: data.userId,
buffer: resultBuffer,
outName,
contentType: resultContentType,
toolId: data.toolId,
});
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
@@ -495,6 +505,37 @@ async function processPipelineStep(job: Job<ToolJobData>): Promise<ToolJobResult
* When part of a pipeline-batch (parentId is set), also records the * When part of a pipeline-batch (parentId is set), also records the
* child outcome for batch progress tracking. * child outcome for batch progress tracking.
*/ */
/** Best-effort content type from a filename extension (for preview dispatch). */
function contentTypeForFilename(name: string): string {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
pdf: "application/pdf",
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
m4a: "audio/mp4",
aac: "audio/aac",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
svg: "image/svg+xml",
bmp: "image/bmp",
tiff: "image/tiff",
heic: "image/heic",
heif: "image/heif",
};
return map[ext] ?? "application/octet-stream";
}
async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobResult> { async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobResult> {
const data = job.data; const data = job.data;
const totalSteps = data.totalSteps ?? 0; const totalSteps = data.totalSteps ?? 0;
@@ -582,6 +623,12 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
const parentKey = `outputs/${data.jobId}/${outFilename}`; const parentKey = `outputs/${data.jobId}/${outFilename}`;
await putObject(parentKey, lastOutputBuffer); await putObject(parentKey, lastOutputBuffer);
// Modality-aware preview of the final output (video poster / pdf first page /
// image thumb) so the pipeline result carries a previewUrl like single-tool
// results do. Content type is derived from the output extension.
const contentType = contentTypeForFilename(outFilename);
const previewRef = await generatePreview(lastOutputBuffer, contentType, data.jobId);
await db await db
.update(schema.jobs) .update(schema.jobs)
.set({ .set({
@@ -593,11 +640,27 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
}) })
.where(eq(schema.jobs.id, data.jobId)); .where(eq(schema.jobs.id, data.jobId));
const result: ToolJobResult = {
outputRefs: [parentKey],
filename: outFilename,
contentType,
originalSize: firstBytesIn,
processedSize: lastBytesOut,
previewRef,
resultPayload: {
stepsCompleted: totalSteps,
steps,
},
};
updateSingleFileProgress({ updateSingleFileProgress({
jobId: progressJobId, jobId: progressJobId,
phase: "complete", phase: "complete",
percent: 100, percent: 100,
stage: "complete", stage: "complete",
// Carry the full result (incl. previewUrl) so the SSE fallback path
// (sync-window timeout) still delivers a downloadable output.
result: buildLegacyResultPayload(result, data.jobId),
}); });
// Batch progress (pipeline-batch only) // Batch progress (pipeline-batch only)
@@ -605,17 +668,7 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
await recordChildOutcome(data.parentId, data.totalFiles, outFilename); await recordChildOutcome(data.parentId, data.totalFiles, outFilename);
} }
return { return result;
outputRefs: [parentKey],
filename: outFilename,
contentType: "application/octet-stream",
originalSize: firstBytesIn,
processedSize: lastBytesOut,
resultPayload: {
stepsCompleted: totalSteps,
steps,
},
};
} }
// ── Batch child handler ─────────────────────────────────────── // ── Batch child handler ───────────────────────────────────────
+2 -2
View File
@@ -101,7 +101,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
if (buffer.length > 0) { if (buffer.length > 0) {
files.push({ files.push({
buffer, buffer,
filename: sanitizeFilename(part.filename ?? "image"), filename: sanitizeFilename(part.filename ?? "file"),
}); });
} }
} else if (part.fieldname === "settings") { } else if (part.fieldname === "settings") {
@@ -121,7 +121,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
} }
if (files.length === 0) { if (files.length === 0) {
return reply.status(400).send({ error: "No image files provided" }); return reply.status(400).send({ error: "No files provided" });
} }
// Enforce batch size limit // Enforce batch size limit
+6 -9
View File
@@ -52,16 +52,12 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
// Skip empty parts (e.g. empty file field) // Skip empty parts (e.g. empty file field)
if (buffer.length === 0) continue; if (buffer.length === 0) continue;
// Validate the image (pass filename for extension-based format detection) // Try image validation; non-image files are accepted with format from extension
const validation = await validateImageBuffer(buffer, part.filename); const validation = await validateImageBuffer(buffer, part.filename).catch(() => null);
if (!validation.valid) { const isValidImage = validation?.valid === true;
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection // Sanitize SVG uploads to prevent XXE, SSRF, and script injection
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer; const safeBuffer = isValidImage && isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
// Sanitize filename (canonical; do NOT re-sanitize downstream) // Sanitize filename (canonical; do NOT re-sanitize downstream)
const safeName = sanitizeFilename(part.filename ?? "upload"); const safeName = sanitizeFilename(part.filename ?? "upload");
@@ -69,10 +65,11 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
// Write to object storage uploads prefix // Write to object storage uploads prefix
await putObject(`uploads/${jobId}/${safeName}`, safeBuffer); await putObject(`uploads/${jobId}/${safeName}`, safeBuffer);
const fileExt = safeName.split(".").pop()?.toLowerCase() ?? "";
uploadedFiles.push({ uploadedFiles.push({
name: safeName, name: safeName,
size: safeBuffer.length, size: safeBuffer.length,
format: validation.format, format: isValidImage ? validation.format : fileExt,
}); });
} }
+7 -4
View File
@@ -205,7 +205,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
*/ */
app.post("/api/v1/pipeline/execute", async (request: FastifyRequest, reply: FastifyReply) => { app.post("/api/v1/pipeline/execute", async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "file";
let pipelineRaw: string | null = null; let pipelineRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
@@ -219,7 +219,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
chunks.push(chunk); chunks.push(chunk);
} }
fileBuffer = Buffer.concat(chunks); fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image"); filename = sanitizeFilename(part.filename ?? "file");
} else if (part.fieldname === "pipeline") { } else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string; pipelineRaw = part.value as string;
} else if (part.fieldname === "clientJobId") { } else if (part.fieldname === "clientJobId") {
@@ -501,6 +501,9 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
return reply.send({ return reply.send({
jobId, jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
previewUrl: result.previewRef
? `/api/v1/download/${jobId}/${result.previewRef.split("/").pop()}`
: undefined,
originalSize, originalSize,
processedSize: result.processedSize, processedSize: result.processedSize,
stepsCompleted: result.resultPayload?.stepsCompleted ?? parsedSteps.length, stepsCompleted: result.resultPayload?.stepsCompleted ?? parsedSteps.length,
@@ -686,7 +689,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
if (buffer.length > 0) { if (buffer.length > 0) {
files.push({ files.push({
buffer, buffer,
filename: sanitizeFilename(part.filename ?? "image"), filename: sanitizeFilename(part.filename ?? "file"),
}); });
} }
} else if (part.fieldname === "pipeline") { } else if (part.fieldname === "pipeline") {
@@ -706,7 +709,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
} }
if (files.length === 0) { if (files.length === 0) {
return reply.status(400).send({ error: "No image files provided" }); return reply.status(400).send({ error: "No files provided" });
} }
// Enforce batch size limit // Enforce batch size limit
+3 -3
View File
@@ -230,7 +230,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const jobId = randomUUID(); const jobId = randomUUID();
const maxInputs = config.maxInputs ?? 1; const maxInputs = config.maxInputs ?? 1;
const minInputs = config.minInputs ?? 1; const minInputs = config.minInputs ?? 1;
let filename = "image"; let filename = "file";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let fileId: string | null = null; let fileId: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
@@ -318,7 +318,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// Require at least one file // Require at least one file
if (received.length === 0) { if (received.length === 0) {
return reply.status(400).send({ error: "No image file provided" }); return reply.status(400).send({ error: "No file provided" });
} }
// Require the tool's minimum number of files (e.g. create-zip / merge-csvs // Require the tool's minimum number of files (e.g. create-zip / merge-csvs
@@ -590,7 +590,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
is_ai_tool: getBundleForTool(config.toolId) !== null, is_ai_tool: getBundleForTool(config.toolId) !== null,
error_code: err instanceof Error ? err.constructor.name : "UnknownError", error_code: err instanceof Error ? err.constructor.name : "UnknownError",
error_message: error_message:
err instanceof Error ? err.message.slice(0, 200) : "Image processing failed", err instanceof Error ? err.message.slice(0, 200) : "Processing failed",
}); });
return reply.status(422).send({ return reply.status(422).send({
error: "Processing failed", error: "Processing failed",
@@ -19,6 +19,7 @@ import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js"; import { resolveOutputFormat } from "../../lib/output-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -144,11 +145,13 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -165,6 +168,8 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -241,12 +246,13 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -12,6 +12,7 @@ import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js"; import { isToolInstalled } from "../../lib/feature-status.js";
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js"; import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
language: z language: z
@@ -102,10 +103,12 @@ export function registerAutoSubtitles(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let filename = "video"; let filename = "video";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -122,6 +125,8 @@ export function registerAutoSubtitles(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -155,12 +160,13 @@ export function registerAutoSubtitles(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
@@ -14,6 +14,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic } from "../../lib/heic-converter.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
const HEX_RE = /^#[0-9a-fA-F]{6}$/; const HEX_RE = /^#[0-9a-fA-F]{6}$/;
@@ -123,11 +124,13 @@ export function registerBackgroundReplace(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -144,6 +147,8 @@ export function registerBackgroundReplace(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -216,12 +221,13 @@ export function registerBackgroundReplace(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -14,6 +14,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic } from "../../lib/heic-converter.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
intensity: z.number().int().min(1).max(100).default(50), intensity: z.number().int().min(1).max(100).default(50),
@@ -93,11 +94,13 @@ export function registerBlurBackground(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -114,6 +117,8 @@ export function registerBlurBackground(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -186,12 +191,13 @@ export function registerBlurBackground(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -18,6 +18,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js"; import { resolveOutputFormat } from "../../lib/output-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -77,11 +78,13 @@ export function registerBlurFaces(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -98,6 +101,8 @@ export function registerBlurFaces(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -161,12 +166,13 @@ export function registerBlurFaces(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -18,6 +18,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js"; import { resolveOutputFormat } from "../../lib/output-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -78,11 +79,13 @@ export function registerColorize(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -99,6 +102,8 @@ export function registerColorize(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -162,12 +167,13 @@ export function registerColorize(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
@@ -66,6 +66,13 @@ export function registerCompressPdf(app: FastifyInstance) {
await gsCompressPdfQuality(inPath, outPath, qualityToDpi(settings.quality ?? 75)); await gsCompressPdfQuality(inPath, outPath, qualityToDpi(settings.quality ?? 75));
} }
// A "Compress" tool must never enlarge the file. If re-encoding produced
// something at least as large as the original (common for already
// compressed or low-DPI scanned PDFs), keep the original bytes instead.
if ((await stat(outPath)).size >= input.buffer.length) {
await writeFile(outPath, input.buffer);
}
ctx.report(95, "Done"); ctx.report(95, "Done");
return { return {
scratchPath: outPath, scratchPath: outPath,
+7 -1
View File
@@ -16,6 +16,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
import { decodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -70,11 +71,13 @@ export function registerEnhanceFaces(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -91,6 +94,8 @@ export function registerEnhanceFaces(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -154,12 +159,13 @@ export function registerEnhanceFaces(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -15,6 +15,7 @@ import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js"; import { resolveOutputFormat } from "../../lib/output-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
format: z format: z
@@ -45,11 +46,13 @@ export function registerEraseObject(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let imageBuffer: Buffer | null = null; let imageBuffer: Buffer | null = null;
let maskBuffer: Buffer | null = null; let maskBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let format = "png"; let format = "png";
let quality = 95; let quality = 95;
let imageKey: string | null = null; let imageKey: string | null = null;
@@ -72,6 +75,8 @@ export function registerEraseObject(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "format") { } else if (part.fieldname === "format") {
format = (part.value as string) || "png"; format = (part.value as string) || "png";
} else if (part.fieldname === "quality") { } else if (part.fieldname === "quality") {
@@ -157,12 +162,13 @@ export function registerEraseObject(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [imageKey, maskKey], inputRefs: [imageKey, maskKey],
filename, filename,
settings: { format, quality }, settings: { format, quality },
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -16,6 +16,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
import { decodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -81,11 +82,13 @@ export function registerNoiseRemoval(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -102,6 +105,8 @@ export function registerNoiseRemoval(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -165,12 +170,13 @@ export function registerNoiseRemoval(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings: parsed, settings: parsed,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -10,6 +10,7 @@ import { enqueueToolJob } from "../../jobs/enqueue.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js"; import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js"; import { isToolInstalled } from "../../lib/feature-status.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
quality: z.enum(["fast", "balanced", "best"]).default("balanced"), quality: z.enum(["fast", "balanced", "best"]).default("balanced"),
@@ -69,10 +70,12 @@ export function registerOcrPdf(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let filename = "document.pdf"; let filename = "document.pdf";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -89,6 +92,8 @@ export function registerOcrPdf(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -122,12 +127,13 @@ export function registerOcrPdf(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -16,6 +16,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
import { decodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -71,11 +72,13 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -92,6 +95,8 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -155,12 +160,13 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
@@ -22,6 +22,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
import { decodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -105,11 +106,13 @@ export function registerRemoveBackground(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -126,6 +129,8 @@ export function registerRemoveBackground(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -207,12 +212,13 @@ export function registerRemoveBackground(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -18,6 +18,7 @@ import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js"; import { resolveOutputFormat } from "../../lib/output-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -94,11 +95,13 @@ export function registerRestorePhoto(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -115,6 +118,8 @@ export function registerRestorePhoto(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -185,12 +190,13 @@ export function registerRestorePhoto(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId: "restore-photo", toolId: "restore-photo",
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
@@ -11,6 +11,7 @@ import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js"; import { isToolInstalled } from "../../lib/feature-status.js";
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js"; import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
language: z language: z
@@ -92,10 +93,12 @@ export function registerTranscribeAudio(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let filename = "audio"; let filename = "audio";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -112,6 +115,8 @@ export function registerTranscribeAudio(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -145,12 +150,13 @@ export function registerTranscribeAudio(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
@@ -17,6 +17,7 @@ import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.j
import { decodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const TOOL_ID = "transparency-fixer"; const TOOL_ID = "transparency-fixer";
@@ -164,11 +165,13 @@ export function registerTransparencyFixer(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -185,6 +188,8 @@ export function registerTransparencyFixer(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -252,12 +257,13 @@ export function registerTransparencyFixer(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId: TOOL_ID, toolId: TOOL_ID,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+7 -1
View File
@@ -19,6 +19,7 @@ import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js"; import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js"; import { resolveOutputFormat } from "../../lib/output-format.js";
import { receiveUpload } from "../../lib/upload-stream.js"; import { receiveUpload } from "../../lib/upload-stream.js";
import { getAuthUser } from "../../plugins/auth.js";
import { registerToolProcessFn } from "../tool-factory.js"; import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -128,11 +129,13 @@ export function registerUpscale(app: FastifyInstance) {
}); });
} }
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID(); const jobId = randomUUID();
let fileBuffer: Buffer | null = null; let fileBuffer: Buffer | null = null;
let filename = "image"; let filename = "image";
let settingsRaw: string | null = null; let settingsRaw: string | null = null;
let clientJobId: string | null = null; let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
@@ -149,6 +152,8 @@ export function registerUpscale(app: FastifyInstance) {
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw; clientJobId = raw;
} }
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} }
} }
} catch (err) { } catch (err) {
@@ -214,12 +219,13 @@ export function registerUpscale(app: FastifyInstance) {
await enqueueToolJob({ await enqueueToolJob({
jobId, jobId,
toolId, toolId,
userId: null, userId,
pool: "ai", pool: "ai",
inputRefs: [inputKey], inputRefs: [inputKey],
filename, filename,
settings, settings,
clientJobId: clientJobId ?? undefined, clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool", kind: "ai-tool",
}); });
+49 -10
View File
@@ -32,6 +32,7 @@ import { sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
import { decodeHeic } from "../lib/heic-converter.js"; import { decodeHeic } from "../lib/heic-converter.js";
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js"; import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
import { pdfFirstPagePreview, videoPosterPreview } from "../modality/preview.js";
import { hasEffectivePermission } from "../permissions.js"; import { hasEffectivePermission } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js"; import { getAuthUser, requireAuth } from "../plugins/auth.js";
@@ -62,6 +63,20 @@ function extToMime(ext: string): string {
tiff: "image/tiff", tiff: "image/tiff",
tif: "image/tiff", tif: "image/tiff",
avif: "image/avif", avif: "image/avif",
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
mp3: "audio/mpeg",
wav: "audio/wav",
flac: "audio/flac",
ogg: "audio/ogg",
aac: "audio/aac",
pdf: "application/pdf",
txt: "text/plain",
csv: "text/csv",
json: "application/json",
xml: "application/xml",
zip: "application/zip",
}; };
return map[clean] ?? "application/octet-stream"; return map[clean] ?? "application/octet-stream";
} }
@@ -480,6 +495,34 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
try { try {
const rawBuffer = await readStoredFile(file.storedName); const rawBuffer = await readStoredFile(file.storedName);
// Video thumbnail via ffmpeg poster frame
if (file.mimeType.startsWith("video/")) {
const poster = await videoPosterPreview(rawBuffer);
if (!poster) {
return reply.status(422).send({ error: "Could not generate thumbnail" });
}
saveThumbnail(file.storedName, poster).catch(() => {});
return reply
.header("Content-Type", "image/webp")
.header("Cache-Control", "public, max-age=86400, immutable")
.send(poster);
}
// PDF thumbnail via ghostscript first page
if (file.mimeType === "application/pdf") {
const page = await pdfFirstPagePreview(rawBuffer);
if (!page) {
return reply.status(422).send({ error: "Could not generate thumbnail" });
}
saveThumbnail(file.storedName, page).catch(() => {});
return reply
.header("Content-Type", "image/png")
.header("Cache-Control", "public, max-age=86400, immutable")
.send(page);
}
// Image thumbnail (existing path)
const validation = await validateImageBuffer(rawBuffer, file.originalName); const validation = await validateImageBuffer(rawBuffer, file.originalName);
let decoded: Buffer<ArrayBuffer> = Buffer.from(rawBuffer); let decoded: Buffer<ArrayBuffer> = Buffer.from(rawBuffer);
if (validation.valid && validation.format === "heif") { if (validation.valid && validation.format === "heif") {
@@ -671,13 +714,9 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: "parentId is required" }); return reply.status(400).send({ error: "parentId is required" });
} }
// Validate the image // Try image validation; non-image outputs from trusted tools are accepted
const validation = await validateImageBuffer(fileBuffer, filename); const validation = await validateImageBuffer(fileBuffer, filename).catch(() => null);
if (!validation.valid) { const isValidImage = validation?.valid === true;
return reply.status(400).send({
error: `Invalid file: ${validation.reason}`,
});
}
// Look up the parent to compute the next version and carry forward the tool chain // Look up the parent to compute the next version and carry forward the tool chain
const [parent] = await db const [parent] = await db
@@ -700,7 +739,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const baseName = parent.originalName.replace(/\.[^.]+$/, ""); const baseName = parent.originalName.replace(/\.[^.]+$/, "");
const resultName = `${baseName}${ext}`; const resultName = `${baseName}${ext}`;
const mimeType = formatToMime(validation.format) || extToMime(ext); const mimeType = isValidImage ? formatToMime(validation.format) : extToMime(ext);
// Sanitize SVG results to prevent XXE, SSRF, and script injection // Sanitize SVG results to prevent XXE, SSRF, and script injection
const safeResultBuffer = isSvgBuffer(fileBuffer) ? sanitizeSvg(fileBuffer) : fileBuffer; const safeResultBuffer = isSvgBuffer(fileBuffer) ? sanitizeSvg(fileBuffer) : fileBuffer;
@@ -727,8 +766,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
storedName, storedName,
mimeType, mimeType,
size: fileSize, size: fileSize,
width: validation.width, width: isValidImage ? validation.width : null,
height: validation.height, height: isValidImage ? validation.height : null,
version: nextVersion, version: nextVersion,
parentId, parentId,
toolChain: newChain, toolChain: newChain,
@@ -6,6 +6,11 @@ import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
/**
* Formats that browsers can render in <img> tags.
* Intentionally image-only: consumers (BeforeAfterSlider, ImageViewer) render via <img>.
* Video/audio/PDF outputs should use dedicated viewer components instead.
*/
const BROWSER_PREVIEWABLE_EXTS = new Set([ const BROWSER_PREVIEWABLE_EXTS = new Set([
"jpg", "jpg",
"jpeg", "jpeg",
@@ -1,52 +1,176 @@
import { import {
AudioLines,
CheckCircle2, CheckCircle2,
File as FileIcon, File as FileIcon,
FileText, FileText,
Film, Film,
Loader2, Loader2,
Music,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import type { FileEntry, PreviewKind } from "@/stores/file-store"; import type { FileEntry, PreviewKind } from "@/stores/file-store";
const BROWSER_IMG_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"]); const BROWSER_IMG_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"]);
const THUMB_W = 104;
const THUMB_H = 76;
/** /** Image URL to show directly in an <img>, or null when the entry has no
* A renderable <img> source for the thumbnail, or null when the entry has no * browser-renderable image (audio/video/PDF inputs get a generated thumb or
* image to show (e.g. audio/video/document originals). Processed previews and * a modality icon instead of a broken <img>). */
* processed image outputs are always real images, so they win when present. function imageThumbSrc(entry: FileEntry): string | null {
*/
function thumbnailImageSrc(entry: FileEntry): string | null {
if (entry.processedPreviewUrl) return entry.processedPreviewUrl; if (entry.processedPreviewUrl) return entry.processedPreviewUrl;
if (entry.processedUrl) { if (entry.processedUrl) {
if (entry.processedUrl.startsWith("blob:")) return entry.processedUrl; const ext =
const ext = decodeURIComponent(entry.processedUrl).split(".").pop()?.toLowerCase() ?? ""; decodeURIComponent(entry.processedUrl).split("?")[0].split(".").pop()?.toLowerCase() ?? "";
if (BROWSER_IMG_EXTS.has(ext)) return entry.processedUrl; if (BROWSER_IMG_EXTS.has(ext)) return entry.processedUrl;
if (entry.processedUrl.startsWith("blob:") && entry.previewKind === "image")
return entry.processedUrl;
} }
// The original blob only renders as an image for image-modality files;
// pointing an <img> at an audio/video/pdf blob just shows a broken icon.
if (entry.previewKind === "image") return entry.blobUrl; if (entry.previewKind === "image") return entry.blobUrl;
return null; return null;
} }
const PLACEHOLDER_ICON: Record<Exclude<PreviewKind, "image">, typeof FileIcon> = { /** Grab the first frame of a video into a small JPEG data URL. Returns a
audio: AudioLines, * cancel fn; calls back with null on any decode/timeout failure. */
video: Film, function captureVideoFrame(src: string, onDone: (url: string | null) => void): () => void {
document: FileText, const video = document.createElement("video");
none: FileIcon, video.muted = true;
}; video.playsInline = true;
video.preload = "auto";
let settled = false;
const timer = setTimeout(() => settle(null), 5000);
function settle(url: string | null) {
if (settled) return;
settled = true;
clearTimeout(timer);
video.removeEventListener("loadeddata", onLoaded);
video.removeEventListener("seeked", onSeeked);
video.removeEventListener("error", onErr);
video.removeAttribute("src");
video.load();
onDone(url);
}
function onLoaded() {
const d = video.duration;
const t = Number.isFinite(d) && d > 0 ? Math.min(0.1, d / 2) : 0;
try {
video.currentTime = t;
} catch {
settle(null);
}
}
function onSeeked() {
const w = video.videoWidth;
const h = video.videoHeight;
if (!w || !h) return settle(null);
try {
const scale = Math.min(THUMB_W / w, THUMB_H / h, 1);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const ctx = canvas.getContext("2d");
if (!ctx) return settle(null);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
settle(canvas.toDataURL("image/jpeg", 0.7));
} catch {
settle(null);
}
}
function onErr() {
settle(null);
}
video.addEventListener("loadeddata", onLoaded);
video.addEventListener("seeked", onSeeked);
video.addEventListener("error", onErr);
video.src = src;
return () => settle(null);
}
/** Icon + format label shown when a file has no image thumbnail. */ /** Render page 1 of a PDF into a small JPEG data URL (pdf.js, lazy-imported). */
function ThumbnailPlaceholder({ entry }: { entry: FileEntry }) { async function renderPdfThumb(file: File, onDone: (url: string | null) => void): Promise<void> {
const kind = entry.previewKind === "image" ? "none" : entry.previewKind; try {
const Icon = PLACEHOLDER_ICON[kind]; const pdfjs = await import("pdfjs-dist");
const ext = (entry.file.name.split(".").pop() ?? "").toUpperCase().slice(0, 4); pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).href;
const loadingTask = pdfjs.getDocument({ data: new Uint8Array(await file.arrayBuffer()) });
const doc = await loadingTask.promise;
const page = await doc.getPage(1);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(THUMB_W / base.width, THUMB_H / base.height, 2);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
await page.render({ canvas, viewport }).promise;
onDone(canvas.toDataURL("image/jpeg", 0.8));
loadingTask.destroy();
} catch {
onDone(null);
}
}
/** Lazily produce a thumbnail data URL for video/PDF entries. */
function useGeneratedThumb(entry: FileEntry): string | null {
const { previewKind, blobUrl, file } = entry;
const isPdf = file.name.toLowerCase().endsWith(".pdf");
const [thumb, setThumb] = useState<string | null>(null);
useEffect(() => {
let active = true;
setThumb(null);
if (previewKind === "video") {
const cancel = captureVideoFrame(blobUrl, (u) => {
if (active) setThumb(u);
});
return () => {
active = false;
cancel();
};
}
if (previewKind === "document" && isPdf) {
renderPdfThumb(file, (u) => {
if (active) setThumb(u);
});
}
return () => {
active = false;
};
}, [previewKind, blobUrl, isPdf, file]);
return thumb;
}
function ModalityIcon({ kind }: { kind: PreviewKind }) {
const cls = "h-4 w-4 text-muted-foreground";
if (kind === "video") return <Film className={cls} />;
if (kind === "audio") return <Music className={cls} />;
if (kind === "document") return <FileText className={cls} />;
return <FileIcon className={cls} />;
}
/** Per-tile content: a real image/thumbnail when available, else a modality
* icon + extension label (never a broken <img>). */
function Thumb({ entry }: { entry: FileEntry }) {
const generated = useGeneratedThumb(entry);
const [imgError, setImgError] = useState(false);
const src = entry.previewKind === "image" ? imageThumbSrc(entry) : generated;
if (src && !imgError) {
return (
<img
src={src}
alt={entry.file.name}
className="w-full h-full object-cover"
draggable={false}
onError={() => setImgError(true)}
/>
);
}
const ext = entry.file.name.split(".").pop()?.toUpperCase().slice(0, 4) ?? "";
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-0.5 bg-muted"> <div className="w-full h-full flex flex-col items-center justify-center gap-0.5 bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" /> <ModalityIcon kind={entry.previewKind} />
{ext && ( {ext && (
<span className="text-[8px] font-semibold leading-none text-muted-foreground">{ext}</span> <span className="text-[7px] leading-none font-medium text-muted-foreground">{ext}</span>
)} )}
</div> </div>
); );
@@ -80,7 +204,6 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
const isSelected = i === selectedIndex; const isSelected = i === selectedIndex;
const isCompleted = entry.status === "completed"; const isCompleted = entry.status === "completed";
const isFailed = entry.status === "failed"; const isFailed = entry.status === "failed";
const imgSrc = thumbnailImageSrc(entry);
return ( return (
<button <button
key={entry.file.name} key={entry.file.name}
@@ -99,15 +222,8 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
<div className="w-full h-full flex items-center justify-center bg-muted"> <div className="w-full h-full flex items-center justify-center bg-muted">
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" /> <Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
</div> </div>
) : imgSrc ? (
<img
src={imgSrc}
alt={entry.file.name}
className="w-full h-full object-cover"
draggable={false}
/>
) : ( ) : (
<ThumbnailPlaceholder entry={entry} /> <Thumb entry={entry} />
)} )}
{isCompleted && ( {isCompleted && (
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center"> <div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
@@ -7,6 +7,13 @@ import { useFileStore } from "@/stores/file-store";
type DocFormat = "docx" | "odt" | "rtf" | "txt"; type DocFormat = "docx" | "odt" | "rtf" | "txt";
const ALL_FORMATS: { value: DocFormat; label: string }[] = [
{ value: "docx", label: "DOCX" },
{ value: "odt", label: "ODT" },
{ value: "rtf", label: "RTF" },
{ value: "txt", label: "TXT" },
];
export function ConvertDocumentSettings() { export function ConvertDocumentSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const s = t.toolSettings["convert-document"]; const s = t.toolSettings["convert-document"];
@@ -16,11 +23,20 @@ export function ConvertDocumentSettings() {
const [outFormat, setOutFormat] = useState<DocFormat>("odt"); const [outFormat, setOutFormat] = useState<DocFormat>("odt");
// Never offer the input's own format as a target. LibreOffice rejects a
// same-format conversion ("already in that format"), so drop it from the
// options and keep the current selection valid.
const inputExt = files[0]?.name.split(".").pop()?.toLowerCase();
const formats = ALL_FORMATS.filter((f) => f.value !== inputExt);
const selected = formats.some((f) => f.value === outFormat)
? outFormat
: (formats[0]?.value ?? outFormat);
const hasFile = files.length > 0; const hasFile = files.length > 0;
const hasMultiple = files.length > 1; const hasMultiple = files.length > 1;
const handleProcess = () => { const handleProcess = () => {
const settings = { format: outFormat }; const settings = { format: selected };
if (hasMultiple) { if (hasMultiple) {
processAllFiles(files, settings); processAllFiles(files, settings);
} else { } else {
@@ -36,14 +52,15 @@ export function ConvertDocumentSettings() {
</label> </label>
<select <select
id="cd-format" id="cd-format"
value={outFormat} value={selected}
onChange={(e) => setOutFormat(e.target.value as DocFormat)} onChange={(e) => setOutFormat(e.target.value as DocFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
> >
<option value="docx">DOCX</option> {formats.map((f) => (
<option value="odt">ODT</option> <option key={f.value} value={f.value}>
<option value="rtf">RTF</option> {f.label}
<option value="txt">TXT</option> </option>
))}
</select> </select>
</div> </div>
@@ -7,6 +7,11 @@ import { useFileStore } from "@/stores/file-store";
type PresFormat = "pptx" | "odp"; type PresFormat = "pptx" | "odp";
const ALL_FORMATS: { value: PresFormat; label: string }[] = [
{ value: "pptx", label: "PPTX" },
{ value: "odp", label: "ODP" },
];
export function ConvertPresentationSettings() { export function ConvertPresentationSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const s = t.toolSettings["convert-presentation"]; const s = t.toolSettings["convert-presentation"];
@@ -16,11 +21,20 @@ export function ConvertPresentationSettings() {
const [outFormat, setOutFormat] = useState<PresFormat>("odp"); const [outFormat, setOutFormat] = useState<PresFormat>("odp");
// Never offer the input's own format as a target. LibreOffice rejects a
// same-format conversion ("already in that format"), so drop it from the
// options and keep the current selection valid.
const inputExt = files[0]?.name.split(".").pop()?.toLowerCase();
const formats = ALL_FORMATS.filter((f) => f.value !== inputExt);
const selected = formats.some((f) => f.value === outFormat)
? outFormat
: (formats[0]?.value ?? outFormat);
const hasFile = files.length > 0; const hasFile = files.length > 0;
const hasMultiple = files.length > 1; const hasMultiple = files.length > 1;
const handleProcess = () => { const handleProcess = () => {
const settings = { format: outFormat }; const settings = { format: selected };
if (hasMultiple) { if (hasMultiple) {
processAllFiles(files, settings); processAllFiles(files, settings);
} else { } else {
@@ -36,12 +50,15 @@ export function ConvertPresentationSettings() {
</label> </label>
<select <select
id="cp-format" id="cp-format"
value={outFormat} value={selected}
onChange={(e) => setOutFormat(e.target.value as PresFormat)} onChange={(e) => setOutFormat(e.target.value as PresFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
> >
<option value="pptx">PPTX</option> {formats.map((f) => (
<option value="odp">ODP</option> <option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select> </select>
</div> </div>
@@ -7,6 +7,12 @@ import { useFileStore } from "@/stores/file-store";
type SheetFormat = "xlsx" | "ods" | "csv"; type SheetFormat = "xlsx" | "ods" | "csv";
const ALL_FORMATS: { value: SheetFormat; label: string }[] = [
{ value: "xlsx", label: "XLSX" },
{ value: "ods", label: "ODS" },
{ value: "csv", label: "CSV" },
];
export function ConvertSpreadsheetSettings() { export function ConvertSpreadsheetSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const s = t.toolSettings["convert-spreadsheet"]; const s = t.toolSettings["convert-spreadsheet"];
@@ -16,11 +22,20 @@ export function ConvertSpreadsheetSettings() {
const [outFormat, setOutFormat] = useState<SheetFormat>("ods"); const [outFormat, setOutFormat] = useState<SheetFormat>("ods");
// Never offer the input's own format as a target. LibreOffice rejects a
// same-format conversion ("already in that format"), so drop it from the
// options and keep the current selection valid.
const inputExt = files[0]?.name.split(".").pop()?.toLowerCase();
const formats = ALL_FORMATS.filter((f) => f.value !== inputExt);
const selected = formats.some((f) => f.value === outFormat)
? outFormat
: (formats[0]?.value ?? outFormat);
const hasFile = files.length > 0; const hasFile = files.length > 0;
const hasMultiple = files.length > 1; const hasMultiple = files.length > 1;
const handleProcess = () => { const handleProcess = () => {
const settings = { format: outFormat }; const settings = { format: selected };
if (hasMultiple) { if (hasMultiple) {
processAllFiles(files, settings); processAllFiles(files, settings);
} else { } else {
@@ -36,13 +51,15 @@ export function ConvertSpreadsheetSettings() {
</label> </label>
<select <select
id="cs-format" id="cs-format"
value={outFormat} value={selected}
onChange={(e) => setOutFormat(e.target.value as SheetFormat)} onChange={(e) => setOutFormat(e.target.value as SheetFormat)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
> >
<option value="xlsx">XLSX</option> {formats.map((f) => (
<option value="ods">ODS</option> <option key={f.value} value={f.value}>
<option value="csv">CSV</option> {f.label}
</option>
))}
</select> </select>
</div> </div>
+32 -3
View File
@@ -38,6 +38,34 @@ const LONG_RUNNING_TOOLS = new Set<string>(["content-aware-resize", "ai-canvas-e
const UPLOAD_WEIGHT = 15; const UPLOAD_WEIGHT = 15;
const SSE_STALL_TIMEOUT_MS = 300_000; const SSE_STALL_TIMEOUT_MS = 300_000;
/** Extension to MIME type for batch ZIP blob construction. Falls back to undefined (generic). */
const MIME_BY_EXT: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
svg: "image/svg+xml",
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
ogv: "video/ogg",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
m4a: "audio/mp4",
aac: "audio/aac",
pdf: "application/pdf",
txt: "text/plain",
csv: "text/csv",
json: "application/json",
xml: "application/xml",
html: "text/html",
zip: "application/zip",
};
export function useToolProcessor(toolId: string) { export function useToolProcessor(toolId: string) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
@@ -116,7 +144,7 @@ export function useToolProcessor(toolId: string) {
if (elapsedRef.current) clearInterval(elapsedRef.current); if (elapsedRef.current) clearInterval(elapsedRef.current);
clearActiveJob(); clearActiveJob();
setError( setError(
"Processing timed out with no progress for 5 minutes. Try again or use a smaller image.", "Processing timed out with no progress for 5 minutes. Try again or use a smaller file.",
); );
setProcessing(false); setProcessing(false);
setProgress(IDLE_PROGRESS); setProgress(IDLE_PROGRESS);
@@ -264,7 +292,7 @@ export function useToolProcessor(toolId: string) {
error: "Processing timed out", error: "Processing timed out",
}); });
setError( setError(
"Processing timed out with no progress for 5 minutes. Try again or use a smaller image.", "Processing timed out with no progress for 5 minutes. Try again or use a smaller file.",
); );
setProcessing(false); setProcessing(false);
setProgress(IDLE_PROGRESS); setProgress(IDLE_PROGRESS);
@@ -604,7 +632,8 @@ export function useToolProcessor(toolId: string) {
for (let i = 0; i < entries.length; i++) { for (let i = 0; i < entries.length; i++) {
const processedName = fileResults[String(i)]; const processedName = fileResults[String(i)];
if (processedName && extracted[processedName]) { if (processedName && extracted[processedName]) {
const blobType = processedName.endsWith(".svg") ? "image/svg+xml" : undefined; const ext = processedName.split(".").pop()?.toLowerCase() ?? "";
const blobType = MIME_BY_EXT[ext];
const blob = new Blob( const blob = new Blob(
[extracted[processedName] as BlobPart], [extracted[processedName] as BlobPart],
blobType ? { type: blobType } : undefined, blobType ? { type: blobType } : undefined,
+10 -5
View File
@@ -66,7 +66,12 @@ const NonNativePreview = lazy(() =>
})), })),
); );
/** Formats that browsers can render in <img> tags. */ /**
* Formats that browsers can render in <img> tags.
* Intentionally image-only: all consumers (BeforeAfterSlider, SideBySideComparison,
* ImageViewer) render via <img>. Video/audio/PDF processed outputs are handled by
* dedicated display-mode branches (media-player, document) before this check runs.
*/
const BROWSER_PREVIEWABLE_EXTS = new Set([ const BROWSER_PREVIEWABLE_EXTS = new Set([
"jpg", "jpg",
"jpeg", "jpeg",
@@ -505,7 +510,7 @@ export function ToolPage() {
const url = URL.createObjectURL(batchZipBlob); const url = URL.createObjectURL(batchZipBlob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = batchZipFilename ?? "processed-images.zip"; a.download = batchZipFilename ?? "processed-files.zip";
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [batchZipBlob, batchZipFilename]); }, [batchZipBlob, batchZipFilename]);
@@ -584,9 +589,9 @@ export function ToolPage() {
const processedFileName = const processedFileName =
currentEntry?.processedFilename ?? currentEntry?.processedFilename ??
(processedUrl (processedUrl
? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image") ? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-file")
: "processed-image"); : "processed-file");
const processedFileType = processedFileName.split(".").pop()?.toUpperCase() || "IMAGE"; const processedFileType = processedFileName.split(".").pop()?.toUpperCase() || "FILE";
const isProcessedPreviewable = processedUrl const isProcessedPreviewable = processedUrl
? canBrowserPreview(processedUrl, currentEntry?.processedFilename ?? processedFileName) ? canBrowserPreview(processedUrl, currentEntry?.processedFilename ?? processedFileName)
: false; : false;
+15 -8
View File
@@ -2,13 +2,11 @@
none remain extractable. Args: {"path": in, "out": out, "terms": [".."], none remain extractable. Args: {"path": in, "out": out, "terms": [".."],
"caseSensitive": false}. Prints {"found": N, "verified": true}. "caseSensitive": false}. Prints {"found": N, "verified": true}.
Case-sensitivity note (PyMuPDF 1.27.2): fitz.Page.search_for is ALWAYS Case sensitivity: fitz.Page.search_for is ALWAYS case-insensitive, so when
case-insensitive. When caseSensitive=true is requested, the search phase caseSensitive=true we post-filter the hits to only those whose on-page glyphs
still finds all case variants (over-redaction, which is the safe direction match the term's exact case (checked with get_textbox). caseSensitive=false
for a legal redaction tool). The verification pass then enforces exact-case redacts every case variant. The verification pass applies the same casing
matching, so a caseSensitive=true request only reports leakage when the rule, so it proves the intended occurrences are gone."""
exact-case term survives. This is the sanctioned fallback documented in
the wave-2 plan."""
import json import json
import sys import sys
@@ -31,7 +29,16 @@ def main():
found = 0 found = 0
for page in doc: for page in doc:
for term in terms: for term in terms:
quads = page.search_for(term, quads=True, flags=flags) if case_sensitive else page.search_for(term, quads=True) if case_sensitive:
# search_for is always case-insensitive; keep only the
# exact-case hits by checking the glyphs under each quad.
quads = [
q
for q in page.search_for(term, quads=True, flags=flags)
if term in page.get_textbox(q.rect)
]
else:
quads = page.search_for(term, quads=True)
for quad in quads: for quad in quads:
page.add_redact_annot(quad, fill=(0, 0, 0)) page.add_redact_annot(quad, fill=(0, 0, 0))
found += 1 found += 1