fix(lint): resolve #280 Lint failures (route formatting + landing import sort)

#280 left 10 section-prefixed custom-route files mis-indented and one
unsorted import block in the new landing section-index page. Fixed via
biome formatter (api) and manual import sort (landing). No config change
(biome.json is hook-protected); no suppression. pnpm lint + typecheck
now exit 0.
This commit is contained in:
SnapOtter
2026-06-21 02:58:46 +08:00
parent 71fefc05b0
commit 6e1b9cd3cc
11 changed files with 1016 additions and 986 deletions
+80 -77
View File
@@ -90,86 +90,89 @@ registerAiJobHandler("auto-subtitles", async (input, data, ctx) => {
}); });
export function registerAutoSubtitles(app: FastifyInstance) { export function registerAutoSubtitles(app: FastifyInstance) {
app.post("/api/v1/tools/video/auto-subtitles", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
const toolId = "auto-subtitles"; "/api/v1/tools/video/auto-subtitles",
if (!isToolInstalled(toolId)) { async (request: FastifyRequest, reply: FastifyReply) => {
const bundle = getBundleForTool(toolId); const toolId = "auto-subtitles";
return reply.status(501).send({ if (!isToolInstalled(toolId)) {
error: "Feature not installed", const bundle = getBundleForTool(toolId);
code: "FEATURE_NOT_INSTALLED", return reply.status(501).send({
feature: TOOL_BUNDLE_MAP[toolId], error: "Feature not installed",
featureName: bundle?.name ?? toolId, code: "FEATURE_NOT_INSTALLED",
estimatedSize: bundle?.estimatedSize ?? "unknown", feature: TOOL_BUNDLE_MAP[toolId],
}); featureName: bundle?.name ?? toolId,
} estimatedSize: bundle?.estimatedSize ?? "unknown",
const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID();
let filename = "video";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
const raw = part.value as string;
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;
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!inputKey) {
return reply.status(400).send({ error: "No video file provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: formatZodErrors(result.error.issues),
}); });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const progressJobId = clientJobId || jobId; const userId = getAuthUser(request)?.id ?? null;
const jobId = randomUUID();
let filename = "video";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let inputKey: string | null = null;
await enqueueToolJob({ try {
jobId, const parts = request.parts();
toolId, for await (const part of parts) {
userId, if (part.type === "file") {
pool: "ai", const upload = await receiveUpload(part, jobId);
inputRefs: [inputKey], inputKey = upload.key;
filename, filename = upload.filename;
settings, } else if (part.fieldname === "settings") {
clientJobId: clientJobId ?? undefined, settingsRaw = part.value as string;
fileId: fileId ?? undefined, } else if (part.fieldname === "clientJobId") {
kind: "ai-tool", const raw = part.value as string;
}); 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;
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
return reply.status(202).send({ jobId: progressJobId, async: true }); if (!inputKey) {
}); return reply.status(400).send({ error: "No video file provided" });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
},
);
} }
+158 -155
View File
@@ -79,189 +79,192 @@ function buildOverlaySvg(
* Read barcodes (all 1D + 2D types) from uploaded images using zxing-wasm. * Read barcodes (all 1D + 2D types) from uploaded images using zxing-wasm.
*/ */
export function registerBarcodeRead(app: FastifyInstance) { export function registerBarcodeRead(app: FastifyInstance) {
app.post("/api/v1/tools/image/barcode-read", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
let fileBuffer: Buffer | null = null; "/api/v1/tools/image/barcode-read",
let filename = "image"; async (request: FastifyRequest, reply: FastifyReply) => {
let settingsRaw: string | null = null; let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
// --- Parse multipart --- // --- Parse multipart ---
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of part.file) { for await (const chunk of part.file) {
chunks.push(chunk); chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} }
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) { if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" }); return reply.status(400).send({ error: "No image file provided" });
}
// --- Validate ---
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid image: ${validation.reason}`,
});
}
// Parse and validate settings
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try { // --- Validate ---
const tryHarder = settings.tryHarder; const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid image: ${validation.reason}`,
});
}
if (validation.format === "heif") { // Parse and validate settings
try { let settings: z.infer<typeof settingsSchema>;
fileBuffer = await decodeHeic(fileBuffer); try {
} catch (err) { const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
return reply.status(422).send({ const result = settingsSchema.safeParse(parsed);
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.", if (!result.success) {
details: err instanceof Error ? err.message : String(err), return reply
}); .status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
if (needsCliDecode(validation.format)) {
try { try {
const fileExt = filename.split(".").pop()?.toLowerCase(); const tryHarder = settings.tryHarder;
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
} catch { if (validation.format === "heif") {
try { try {
await sharp(fileBuffer).metadata(); fileBuffer = await decodeHeic(fileBuffer);
} catch (err) { } catch (err) {
return reply.status(422).send({ return reply.status(422).send({
error: `Failed to decode ${validation.format.toUpperCase()} file`, error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err), details: err instanceof Error ? err.message : String(err),
}); });
} }
} }
} if (needsCliDecode(validation.format)) {
if (validation.format === "svg") { try {
try { const fileExt = filename.split(".").pop()?.toLowerCase();
fileBuffer = decompressSvgz(fileBuffer); fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
fileBuffer = sanitizeSvg(fileBuffer); } catch {
} catch (err) { try {
return reply.status(400).send({ await sharp(fileBuffer).metadata();
error: err instanceof Error ? err.message : "Invalid SVG", } catch (err) {
return reply.status(422).send({
error: `Failed to decode ${validation.format.toUpperCase()} file`,
details: err instanceof Error ? err.message : String(err),
});
}
}
}
if (validation.format === "svg") {
try {
fileBuffer = decompressSvgz(fileBuffer);
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
}
fileBuffer = await autoOrient(fileBuffer);
// Convert to raw RGBA pixel data
const image = sharp(fileBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
if (width === 0 || height === 0) {
return reply.status(422).send({
error: "Could not determine image dimensions",
}); });
} }
}
fileBuffer = await autoOrient(fileBuffer);
// Convert to raw RGBA pixel data const rawData = await image.ensureAlpha().raw().toBuffer();
const image = sharp(fileBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
if (width === 0 || height === 0) { // --- Detect barcodes via zxing-wasm ---
return reply.status(422).send({ const imageData = {
error: "Could not determine image dimensions", data: new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
width,
height,
} as ImageData;
const results = await readBarcodes(imageData, {
tryHarder,
maxNumberOfSymbols: 255,
}); });
}
const rawData = await image.ensureAlpha().raw().toBuffer(); const validResults = results.filter((r) => r.isValid);
// --- Detect barcodes via zxing-wasm --- // Map to the response shape
const imageData = { const barcodes = validResults.map((r) => ({
data: new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length), type: r.format,
width, text: r.text,
height, position: {
} as ImageData; topLeft: { x: r.position.topLeft.x, y: r.position.topLeft.y },
topRight: { x: r.position.topRight.x, y: r.position.topRight.y },
const results = await readBarcodes(imageData, { bottomLeft: {
tryHarder, x: r.position.bottomLeft.x,
maxNumberOfSymbols: 255, y: r.position.bottomLeft.y,
}); },
bottomRight: {
const validResults = results.filter((r) => r.isValid); x: r.position.bottomRight.x,
y: r.position.bottomRight.y,
// Map to the response shape },
const barcodes = validResults.map((r) => ({
type: r.format,
text: r.text,
position: {
topLeft: { x: r.position.topLeft.x, y: r.position.topLeft.y },
topRight: { x: r.position.topRight.x, y: r.position.topRight.y },
bottomLeft: {
x: r.position.bottomLeft.x,
y: r.position.bottomLeft.y,
}, },
bottomRight: { }));
x: r.position.bottomRight.x,
y: r.position.bottomRight.y, // No barcodes found - return early
}, if (barcodes.length === 0) {
}, return reply.send({
})); filename,
barcodes: [],
annotatedUrl: null,
previewUrl: null,
});
}
// --- Generate annotated image ---
const jobId = randomUUID();
// Save original input
await putObject(`uploads/${jobId}/${filename}`, fileBuffer);
// Build SVG overlay with bounding boxes
const overlaySvg = buildOverlaySvg(width, height, barcodes);
const stem = filename.replace(/\.[^.]+$/, "");
const outputFilename = `annotated-${stem}.png`;
const annotatedBuffer = await sharp(fileBuffer)
.composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }])
.png()
.toBuffer();
await putObject(`outputs/${jobId}/${outputFilename}`, annotatedBuffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
// No barcodes found - return early
if (barcodes.length === 0) {
return reply.send({ return reply.send({
filename, filename,
barcodes: [], barcodes,
annotatedUrl: null, annotatedUrl: downloadUrl,
previewUrl: null, previewUrl: downloadUrl,
});
} catch (err) {
request.log.error({ err, toolId: "barcode-read" }, "Barcode read failed");
return reply.status(422).send({
error: "Barcode reading failed",
details: err instanceof Error ? err.message : "Unknown error",
}); });
} }
},
// --- Generate annotated image --- );
const jobId = randomUUID();
// Save original input
await putObject(`uploads/${jobId}/${filename}`, fileBuffer);
// Build SVG overlay with bounding boxes
const overlaySvg = buildOverlaySvg(width, height, barcodes);
const stem = filename.replace(/\.[^.]+$/, "");
const outputFilename = `annotated-${stem}.png`;
const annotatedBuffer = await sharp(fileBuffer)
.composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }])
.png()
.toBuffer();
await putObject(`outputs/${jobId}/${outputFilename}`, annotatedBuffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
return reply.send({
filename,
barcodes,
annotatedUrl: downloadUrl,
previewUrl: downloadUrl,
});
} catch (err) {
request.log.error({ err, toolId: "barcode-read" }, "Barcode read failed");
return reply.status(422).send({
error: "Barcode reading failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
} }
+109 -106
View File
@@ -65,119 +65,122 @@ registerAiJobHandler("blur-faces", async (input, data, ctx) => {
/** Face detection and blurring route. */ /** Face detection and blurring route. */
export function registerBlurFaces(app: FastifyInstance) { export function registerBlurFaces(app: FastifyInstance) {
app.post("/api/v1/tools/image/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
const toolId = "blur-faces"; "/api/v1/tools/image/blur-faces",
if (!isToolInstalled(toolId)) { async (request: FastifyRequest, reply: FastifyReply) => {
const bundle = getBundleForTool(toolId); const toolId = "blur-faces";
return reply.status(501).send({ if (!isToolInstalled(toolId)) {
error: "Feature not installed", const bundle = getBundleForTool(toolId);
code: "FEATURE_NOT_INSTALLED", return reply.status(501).send({
feature: TOOL_BUNDLE_MAP[toolId], error: "Feature not installed",
featureName: bundle?.name ?? toolId, code: "FEATURE_NOT_INSTALLED",
estimatedSize: bundle?.estimatedSize ?? "unknown", feature: TOOL_BUNDLE_MAP[toolId],
}); featureName: bundle?.name ?? toolId,
} estimatedSize: bundle?.estimatedSize ?? "unknown",
});
}
const userId = getAuthUser(request)?.id ?? null; 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 fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
const upload = await receiveUpload(part, jobId); const upload = await receiveUpload(part, jobId);
inputKey = upload.key; inputKey = upload.key;
filename = upload.filename; filename = upload.filename;
} else if (part.fieldname === "settings") { } else if (part.fieldname === "settings") {
settingsRaw = part.value as string; settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") { } else if (part.fieldname === "clientJobId") {
const raw = part.value as string; const raw = part.value as string;
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 === "fileId") {
fileId = part.value as string;
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
} }
} catch (err) {
return reply.status(400).send({ if (!inputKey) {
error: "Failed to parse multipart request", return reply.status(400).send({ error: "No image file provided" });
details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face blur failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
}); });
}
if (!inputKey) { return reply.status(202).send({ jobId: progressJobId, async: true });
return reply.status(400).send({ error: "No image file provided" }); },
} );
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face blur failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry // Register in the pipeline/batch registry
registerToolProcessFn({ registerToolProcessFn({
+91 -88
View File
@@ -109,107 +109,110 @@ export function registerEditMetadata(app: FastifyInstance) {
); );
// Edit endpoint - writes metadata in-place using ExifTool (no pixel re-encoding) // Edit endpoint - writes metadata in-place using ExifTool (no pixel re-encoding)
app.post("/api/v1/tools/image/edit-metadata", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
let fileBuffer: Buffer | null = null; "/api/v1/tools/image/edit-metadata",
let filename = "image"; async (request: FastifyRequest, reply: FastifyReply) => {
let settingsRaw: string | null = null; let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of part.file) { for await (const chunk of part.file) {
chunks.push(chunk); chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} }
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} }
} } catch (err) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Parse and validate settings
let settings: Settings;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ return reply.status(400).send({
error: "Invalid settings", error: "Failed to parse multipart request",
details: formatZodErrors(result.error.issues), details: err instanceof Error ? err.message : String(err),
}); });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try { if (!fileBuffer || fileBuffer.length === 0) {
// Build ExifTool arguments from settings return reply.status(400).send({ error: "No image file provided" });
const tags = buildTagArgs(settings as EditMetadataSettings);
// If no changes requested, return the original buffer
let outputBuffer: Buffer;
if (tags.length === 0) {
outputBuffer = fileBuffer;
} else {
outputBuffer = await writeMetadata(fileBuffer, filename, tags);
} }
// Determine content type from validated format const validation = await validateImageBuffer(fileBuffer, filename);
const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg"; if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Save output to object storage // Parse and validate settings
const jobId = randomUUID(); let settings: Settings;
await putObject(`outputs/${jobId}/${filename}`, outputBuffer); try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
// Generate preview for non-browser-previewable formats (HEIF, TIFF) const result = settingsSchema.safeParse(parsed);
let previewUrl: string | undefined; if (!result.success) {
if (!BROWSER_PREVIEWABLE.has(contentType)) { return reply.status(400).send({
try { error: "Invalid settings",
let previewInput = outputBuffer; details: formatZodErrors(result.error.issues),
if (contentType === "image/heif" || contentType === "image/heic") { });
previewInput = await decodeHeic(outputBuffer);
}
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
await putObject(`outputs/${jobId}/preview.webp`, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend shows fallback
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
return reply.send({ try {
jobId, // Build ExifTool arguments from settings
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`, const tags = buildTagArgs(settings as EditMetadataSettings);
previewUrl,
originalSize: fileBuffer.length, // If no changes requested, return the original buffer
processedSize: outputBuffer.length, let outputBuffer: Buffer;
}); if (tags.length === 0) {
} catch (err) { outputBuffer = fileBuffer;
request.log.error({ err, toolId: "edit-metadata" }, "Metadata edit failed"); } else {
return reply.status(422).send({ outputBuffer = await writeMetadata(fileBuffer, filename, tags);
error: "Metadata edit failed", }
details: err instanceof Error ? err.message : "Unknown error",
}); // Determine content type from validated format
} const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg";
});
// Save output to object storage
const jobId = randomUUID();
await putObject(`outputs/${jobId}/${filename}`, outputBuffer);
// Generate preview for non-browser-previewable formats (HEIF, TIFF)
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(contentType)) {
try {
let previewInput = outputBuffer;
if (contentType === "image/heif" || contentType === "image/heic") {
previewInput = await decodeHeic(outputBuffer);
}
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
await putObject(`outputs/${jobId}/preview.webp`, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend shows fallback
}
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
});
} catch (err) {
request.log.error({ err, toolId: "edit-metadata" }, "Metadata edit failed");
return reply.status(422).send({
error: "Metadata edit failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
// Register in pipeline/batch registry // Register in pipeline/batch registry
registerToolProcessFn({ registerToolProcessFn({
+109 -106
View File
@@ -58,119 +58,122 @@ registerAiJobHandler("enhance-faces", async (input, data, ctx) => {
/** Face enhancement route using GFPGAN/CodeFormer. */ /** Face enhancement route using GFPGAN/CodeFormer. */
export function registerEnhanceFaces(app: FastifyInstance) { export function registerEnhanceFaces(app: FastifyInstance) {
app.post("/api/v1/tools/image/enhance-faces", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
const toolId = "enhance-faces"; "/api/v1/tools/image/enhance-faces",
if (!isToolInstalled(toolId)) { async (request: FastifyRequest, reply: FastifyReply) => {
const bundle = getBundleForTool(toolId); const toolId = "enhance-faces";
return reply.status(501).send({ if (!isToolInstalled(toolId)) {
error: "Feature not installed", const bundle = getBundleForTool(toolId);
code: "FEATURE_NOT_INSTALLED", return reply.status(501).send({
feature: TOOL_BUNDLE_MAP[toolId], error: "Feature not installed",
featureName: bundle?.name ?? toolId, code: "FEATURE_NOT_INSTALLED",
estimatedSize: bundle?.estimatedSize ?? "unknown", feature: TOOL_BUNDLE_MAP[toolId],
}); featureName: bundle?.name ?? toolId,
} estimatedSize: bundle?.estimatedSize ?? "unknown",
});
}
const userId = getAuthUser(request)?.id ?? null; 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 fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
const upload = await receiveUpload(part, jobId); const upload = await receiveUpload(part, jobId);
inputKey = upload.key; inputKey = upload.key;
filename = upload.filename; filename = upload.filename;
} else if (part.fieldname === "settings") { } else if (part.fieldname === "settings") {
settingsRaw = part.value as string; settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") { } else if (part.fieldname === "clientJobId") {
const raw = part.value as string; const raw = part.value as string;
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 === "fileId") {
fileId = part.value as string;
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
} }
} catch (err) {
return reply.status(400).send({ if (!inputKey) {
error: "Failed to parse multipart request", return reply.status(400).send({ error: "No image file provided" });
details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
}); });
}
if (!inputKey) { return reply.status(202).send({ jobId: progressJobId, async: true });
return reply.status(400).send({ error: "No image file provided" }); },
} );
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry // Register in the pipeline/batch registry
registerToolProcessFn({ registerToolProcessFn({
+134 -131
View File
@@ -33,147 +33,150 @@ const settingsSchema = z.object({
* via getObjectBuffer(data.inputRefs[1]) inside the handler. * via getObjectBuffer(data.inputRefs[1]) inside the handler.
*/ */
export function registerEraseObject(app: FastifyInstance) { export function registerEraseObject(app: FastifyInstance) {
app.post("/api/v1/tools/image/erase-object", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
const toolId = "erase-object"; "/api/v1/tools/image/erase-object",
if (!isToolInstalled(toolId)) { async (request: FastifyRequest, reply: FastifyReply) => {
const bundle = getBundleForTool(toolId); const toolId = "erase-object";
return reply.status(501).send({ if (!isToolInstalled(toolId)) {
error: "Feature not installed", const bundle = getBundleForTool(toolId);
code: "FEATURE_NOT_INSTALLED", return reply.status(501).send({
feature: TOOL_BUNDLE_MAP[toolId], error: "Feature not installed",
featureName: bundle?.name ?? toolId, code: "FEATURE_NOT_INSTALLED",
estimatedSize: bundle?.estimatedSize ?? "unknown", feature: TOOL_BUNDLE_MAP[toolId],
}); featureName: bundle?.name ?? toolId,
} estimatedSize: bundle?.estimatedSize ?? "unknown",
});
}
const userId = getAuthUser(request)?.id ?? null; 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 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;
let maskKey: string | null = null; let maskKey: string | null = null;
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
if (part.fieldname === "mask") { if (part.fieldname === "mask") {
const upload = await receiveUpload(part, jobId); const upload = await receiveUpload(part, jobId);
maskKey = upload.key; maskKey = upload.key;
} else { } else {
const upload = await receiveUpload(part, jobId); const upload = await receiveUpload(part, jobId);
imageKey = upload.key; imageKey = upload.key;
filename = upload.filename; filename = upload.filename;
}
} else if (part.fieldname === "clientJobId") {
const raw = part.value as string;
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;
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "format") {
format = (part.value as string) || "png";
} else if (part.fieldname === "quality") {
quality = Number(part.value) || 95;
} }
} else if (part.fieldname === "clientJobId") {
const raw = part.value as string;
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;
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "format") {
format = (part.value as string) || "png";
} else if (part.fieldname === "quality") {
quality = Number(part.value) || 95;
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!imageKey) { if (!imageKey) {
return reply.status(400).send({ error: "No image file provided" }); return reply.status(400).send({ error: "No image file provided" });
}
if (!maskKey) {
return reply.status(400).send({
error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'",
});
}
imageBuffer = await getObjectBuffer(imageKey);
maskBuffer = await getObjectBuffer(maskKey);
const imageValidation = await validateImageBuffer(imageBuffer, filename);
if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
}
const maskValidation = await validateImageBuffer(maskBuffer, "mask.png");
if (!maskValidation.valid) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
try {
if (imageValidation.format === "heif") {
imageBuffer = await decodeHeic(imageBuffer);
} }
if (needsCliDecode(imageValidation.format)) { if (!maskKey) {
imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format); return reply.status(400).send({
error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'",
});
} }
imageBuffer = await autoOrient(imageBuffer);
} catch (err) { imageBuffer = await getObjectBuffer(imageKey);
request.log.error({ err, toolId: "erase-object" }, "Input decoding failed"); maskBuffer = await getObjectBuffer(maskKey);
return reply.status(422).send({
error: "Object erasing failed", const imageValidation = await validateImageBuffer(imageBuffer, filename);
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
}
const maskValidation = await validateImageBuffer(maskBuffer, "mask.png");
if (!maskValidation.valid) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
try {
if (imageValidation.format === "heif") {
imageBuffer = await decodeHeic(imageBuffer);
}
if (needsCliDecode(imageValidation.format)) {
imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format);
}
imageBuffer = await autoOrient(imageBuffer);
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Input decoding failed");
return reply.status(422).send({
error: "Object erasing failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
// Write decoded image for the worker
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== imageKey) {
await putObject(decodedKey, imageBuffer);
imageKey = decodedKey;
} else {
await putObject(imageKey, imageBuffer);
}
const progressJobId = clientJobId || jobId;
// Enqueue with both image and mask as inputRefs; the worker handler
// reads them via getObjectBuffer.
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [imageKey, maskKey],
filename,
settings: { format, quality },
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
}); });
}
// Write decoded image for the worker return reply.status(202).send({ jobId: progressJobId, async: true });
const decodedKey = `uploads/${jobId}/${filename}`; },
if (decodedKey !== imageKey) { );
await putObject(decodedKey, imageBuffer);
imageKey = decodedKey;
} else {
await putObject(imageKey, imageBuffer);
}
const progressJobId = clientJobId || jobId;
// Enqueue with both image and mask as inputRefs; the worker handler
// reads them via getObjectBuffer.
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [imageKey, maskKey],
filename,
settings: { format, quality },
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
} }
// ── AI job handler (separate import for the worker) ─────────────── // ── AI job handler (separate import for the worker) ───────────────
+36 -33
View File
@@ -98,46 +98,49 @@ const settingsSchema = z.object({
export function registerGifTools(app: FastifyInstance) { export function registerGifTools(app: FastifyInstance) {
// ── Metadata endpoint ─────────────────────────────────────────── // ── Metadata endpoint ───────────────────────────────────────────
app.post("/api/v1/tools/image/gif-tools/info", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
let fileBuffer: Buffer | null = null; "/api/v1/tools/image/gif-tools/info",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of part.file) { for await (const chunk of part.file) {
chunks.push(chunk); chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
} }
fileBuffer = Buffer.concat(chunks);
} }
} catch {
return reply.status(400).send({ error: "Failed to parse request" });
} }
} catch {
return reply.status(400).send({ error: "Failed to parse request" });
}
if (!fileBuffer || fileBuffer.length === 0) { if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No file provided" }); return reply.status(400).send({ error: "No file provided" });
} }
try { try {
const meta = await sharp(fileBuffer).metadata(); const meta = await sharp(fileBuffer).metadata();
const pages = meta.pages ?? 1; const pages = meta.pages ?? 1;
const delay = meta.delay ?? Array(pages).fill(100); const delay = meta.delay ?? Array(pages).fill(100);
return reply.send({ return reply.send({
width: meta.width ?? 0, width: meta.width ?? 0,
height: meta.pageHeight ?? meta.height ?? 0, height: meta.pageHeight ?? meta.height ?? 0,
pages, pages,
delay, delay,
loop: meta.loop ?? 0, loop: meta.loop ?? 0,
fileSize: fileBuffer.length, fileSize: fileBuffer.length,
duration: delay.reduce((sum: number, d: number) => sum + d, 0), duration: delay.reduce((sum: number, d: number) => sum + d, 0),
}); });
} catch { } catch {
return reply.status(422).send({ error: "Could not read image metadata" }); return reply.status(422).send({ error: "Could not read image metadata" });
} }
}); },
);
// ── Processing endpoint ───────────────────────────────────────── // ── Processing endpoint ─────────────────────────────────────────
createToolRoute(app, { createToolRoute(app, {
+109 -106
View File
@@ -69,119 +69,122 @@ registerAiJobHandler("noise-removal", async (input, data, ctx) => {
* Uses the Python sidecar for multi-tier denoising. * Uses the Python sidecar for multi-tier denoising.
*/ */
export function registerNoiseRemoval(app: FastifyInstance) { export function registerNoiseRemoval(app: FastifyInstance) {
app.post("/api/v1/tools/image/noise-removal", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
const toolId = "noise-removal"; "/api/v1/tools/image/noise-removal",
if (!isToolInstalled(toolId)) { async (request: FastifyRequest, reply: FastifyReply) => {
const bundle = getBundleForTool(toolId); const toolId = "noise-removal";
return reply.status(501).send({ if (!isToolInstalled(toolId)) {
error: "Feature not installed", const bundle = getBundleForTool(toolId);
code: "FEATURE_NOT_INSTALLED", return reply.status(501).send({
feature: TOOL_BUNDLE_MAP[toolId], error: "Feature not installed",
featureName: bundle?.name ?? toolId, code: "FEATURE_NOT_INSTALLED",
estimatedSize: bundle?.estimatedSize ?? "unknown", feature: TOOL_BUNDLE_MAP[toolId],
}); featureName: bundle?.name ?? toolId,
} estimatedSize: bundle?.estimatedSize ?? "unknown",
});
}
const userId = getAuthUser(request)?.id ?? null; 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 fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
const upload = await receiveUpload(part, jobId); const upload = await receiveUpload(part, jobId);
inputKey = upload.key; inputKey = upload.key;
filename = upload.filename; filename = upload.filename;
} else if (part.fieldname === "settings") { } else if (part.fieldname === "settings") {
settingsRaw = part.value as string; settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") { } else if (part.fieldname === "clientJobId") {
const raw = part.value as string; const raw = part.value as string;
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 === "fileId") {
fileId = part.value as string;
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
} }
} catch (err) {
return reply.status(400).send({ if (!inputKey) {
error: "Failed to parse multipart request", return reply.status(400).send({ error: "No image file provided" });
details: stripInternalPaths(err instanceof Error ? err.message : String(err)), }
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let parsed: z.infer<typeof settingsSchema>;
try {
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(raw);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
parsed = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Input decoding failed");
return reply.status(422).send({
error: "Noise removal failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings: parsed,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
}); });
}
if (!inputKey) { return reply.status(202).send({ jobId: progressJobId, async: true });
return reply.status(400).send({ error: "No image file provided" }); },
} );
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let parsed: z.infer<typeof settingsSchema>;
try {
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(raw);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
parsed = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Input decoding failed");
return reply.status(422).send({
error: "Noise removal failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId,
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings: parsed,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry // Register in the pipeline/batch registry
registerToolProcessFn({ registerToolProcessFn({
+79 -76
View File
@@ -30,83 +30,86 @@ const settingsSchema = z.object({
* images from text input, not from uploaded files. * images from text input, not from uploaded files.
*/ */
export function registerQrGenerate(app: FastifyInstance) { export function registerQrGenerate(app: FastifyInstance) {
app.post("/api/v1/tools/image/qr-generate", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
let body: unknown; "/api/v1/tools/image/qr-generate",
try { async (request: FastifyRequest, reply: FastifyReply) => {
body = request.body; let body: unknown;
} catch { try {
return reply.status(400).send({ error: "Invalid request body" }); body = request.body;
} } catch {
return reply.status(400).send({ error: "Invalid request body" });
const result = settingsSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: formatZodErrors(result.error.issues),
});
}
const settings = result.data;
try {
// When a logo is present, force max error correction so the QR
// remains scannable despite the logo occluding the center.
const ecLevel = settings.logoDataUri ? "H" : settings.errorCorrection;
let buffer = await QRCode.toBuffer(settings.text, {
width: settings.size,
errorCorrectionLevel: ecLevel,
color: {
dark: settings.foreground,
light: settings.background,
},
type: "png",
margin: 2,
});
if (settings.logoDataUri) {
// Decode the data-URI base64 payload into a buffer
const base64Part = settings.logoDataUri.split(",")[1];
let logoBuffer: Buffer;
try {
logoBuffer = Buffer.from(base64Part, "base64");
// Validate that sharp can decode it
await sharp(logoBuffer).metadata();
} catch {
return reply.status(400).send({ error: "Invalid logo image" });
}
// Resize logo to 22% of QR size and composite centered
const logoSize = Math.round(settings.size * 0.22);
const resizedLogo = await sharp(logoBuffer)
.resize(logoSize, logoSize, {
fit: "contain",
background: { r: 255, g: 255, b: 255, alpha: 0 },
})
.png()
.toBuffer();
buffer = await sharp(buffer)
.composite([{ input: resizedLogo, gravity: "centre" }])
.png()
.toBuffer();
} }
const jobId = randomUUID(); const result = settingsSchema.safeParse(body);
const filename = "qrcode.png"; if (!result.success) {
await putObject(`outputs/${jobId}/${filename}`, buffer); return reply.status(400).send({
error: "Invalid settings",
details: formatZodErrors(result.error.issues),
});
}
return reply.send({ const settings = result.data;
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`, try {
originalSize: 0, // When a logo is present, force max error correction so the QR
processedSize: buffer.length, // remains scannable despite the logo occluding the center.
}); const ecLevel = settings.logoDataUri ? "H" : settings.errorCorrection;
} catch (err) {
return reply.status(422).send({ let buffer = await QRCode.toBuffer(settings.text, {
error: "QR code generation failed", width: settings.size,
details: err instanceof Error ? err.message : "Unknown error", errorCorrectionLevel: ecLevel,
}); color: {
} dark: settings.foreground,
}); light: settings.background,
},
type: "png",
margin: 2,
});
if (settings.logoDataUri) {
// Decode the data-URI base64 payload into a buffer
const base64Part = settings.logoDataUri.split(",")[1];
let logoBuffer: Buffer;
try {
logoBuffer = Buffer.from(base64Part, "base64");
// Validate that sharp can decode it
await sharp(logoBuffer).metadata();
} catch {
return reply.status(400).send({ error: "Invalid logo image" });
}
// Resize logo to 22% of QR size and composite centered
const logoSize = Math.round(settings.size * 0.22);
const resizedLogo = await sharp(logoBuffer)
.resize(logoSize, logoSize, {
fit: "contain",
background: { r: 255, g: 255, b: 255, alpha: 0 },
})
.png()
.toBuffer();
buffer = await sharp(buffer)
.composite([{ input: resizedLogo, gravity: "centre" }])
.png()
.toBuffer();
}
const jobId = randomUUID();
const filename = "qrcode.png";
await putObject(`outputs/${jobId}/${filename}`, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: 0,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "QR code generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
} }
+109 -106
View File
@@ -83,125 +83,128 @@ registerAiJobHandler("restore-photo", async (input, data, ctx) => {
* optional colorization. * optional colorization.
*/ */
export function registerRestorePhoto(app: FastifyInstance) { export function registerRestorePhoto(app: FastifyInstance) {
app.post("/api/v1/tools/image/restore-photo", async (request: FastifyRequest, reply: FastifyReply) => { app.post(
if (!isToolInstalled("restore-photo")) { "/api/v1/tools/image/restore-photo",
const bundle = getBundleForTool("restore-photo"); async (request: FastifyRequest, reply: FastifyReply) => {
return reply.status(501).send({ if (!isToolInstalled("restore-photo")) {
error: "Feature not installed", const bundle = getBundleForTool("restore-photo");
code: "FEATURE_NOT_INSTALLED", return reply.status(501).send({
feature: "photo-restoration", error: "Feature not installed",
featureName: bundle?.name ?? "Photo Restoration", code: "FEATURE_NOT_INSTALLED",
estimatedSize: bundle?.estimatedSize ?? "unknown", feature: "photo-restoration",
}); featureName: bundle?.name ?? "Photo Restoration",
} estimatedSize: bundle?.estimatedSize ?? "unknown",
});
}
const userId = getAuthUser(request)?.id ?? null; 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 fileId: string | null = null;
let inputKey: string | null = null; let inputKey: string | null = null;
try { try {
const parts = request.parts(); const parts = request.parts();
for await (const part of parts) { for await (const part of parts) {
if (part.type === "file") { if (part.type === "file") {
const upload = await receiveUpload(part, jobId); const upload = await receiveUpload(part, jobId);
inputKey = upload.key; inputKey = upload.key;
filename = upload.filename; filename = upload.filename;
} else if (part.fieldname === "settings") { } else if (part.fieldname === "settings") {
settingsRaw = part.value as string; settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") { } else if (part.fieldname === "clientJobId") {
const raw = part.value as string; const raw = part.value as string;
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 === "fileId") {
fileId = part.value as string;
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
} }
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!inputKey) { if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" }); return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try { fileBuffer = await getObjectBuffer(inputKey);
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer); const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
} }
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); let settings: z.infer<typeof settingsSchema>;
} try {
fileBuffer = await autoOrient(fileBuffer); const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
if (validation.format === "avif") { const result = settingsSchema.safeParse(parsed);
try { if (!result.success) {
fileBuffer = await sharp(fileBuffer).png().toBuffer(); return reply
} catch { .status(400)
fileBuffer = await decodeAnyFormat(fileBuffer, "avif"); .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed"); try {
return reply.status(422).send({ if (validation.format === "heif") {
error: "Photo restoration failed", fileBuffer = await decodeHeic(fileBuffer);
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"), }
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
fileBuffer = await autoOrient(fileBuffer);
if (validation.format === "avif") {
try {
fileBuffer = await sharp(fileBuffer).png().toBuffer();
} catch {
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
}
}
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId: "restore-photo",
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
}); });
}
const decodedKey = `uploads/${jobId}/${filename}`; return reply.status(202).send({ jobId: progressJobId, async: true });
if (decodedKey !== inputKey) { },
await putObject(decodedKey, fileBuffer); );
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
await enqueueToolJob({
jobId,
toolId: "restore-photo",
userId,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry // Register in the pipeline/batch registry
registerToolProcessFn({ registerToolProcessFn({
@@ -1,8 +1,8 @@
--- ---
import { SECTIONS, TOOLS, toolSection } from "@snapotter/shared"; import { SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
import Base from "@/layouts/Base.astro";
import Navbar from "@/components/Navbar.astro";
import Footer from "@/components/Footer.astro"; import Footer from "@/components/Footer.astro";
import Navbar from "@/components/Navbar.astro";
import Base from "@/layouts/Base.astro";
export function getStaticPaths() { export function getStaticPaths() {
return SECTIONS.map((s) => ({ return SECTIONS.map((s) => ({