mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -90,86 +90,89 @@ registerAiJobHandler("auto-subtitles", async (input, data, ctx) => {
|
||||
});
|
||||
|
||||
export function registerAutoSubtitles(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/video/auto-subtitles", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "auto-subtitles";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
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),
|
||||
app.post(
|
||||
"/api/v1/tools/video/auto-subtitles",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "auto-subtitles";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
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({
|
||||
jobId,
|
||||
toolId,
|
||||
userId,
|
||||
pool: "ai",
|
||||
inputRefs: [inputKey],
|
||||
filename,
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
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)),
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,189 +79,192 @@ function buildOverlaySvg(
|
||||
* Read barcodes (all 1D + 2D types) from uploaded images using zxing-wasm.
|
||||
*/
|
||||
export function registerBarcodeRead(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/barcode-read", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
app.post(
|
||||
"/api/v1/tools/image/barcode-read",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
|
||||
// --- Parse multipart ---
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
// --- Parse multipart ---
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
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) {
|
||||
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) });
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
const tryHarder = settings.tryHarder;
|
||||
// --- Validate ---
|
||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||
if (!validation.valid) {
|
||||
return reply.status(400).send({
|
||||
error: `Invalid image: ${validation.reason}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (validation.format === "heif") {
|
||||
try {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
// 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" });
|
||||
}
|
||||
if (needsCliDecode(validation.format)) {
|
||||
try {
|
||||
const fileExt = filename.split(".").pop()?.toLowerCase();
|
||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
||||
} catch {
|
||||
|
||||
try {
|
||||
const tryHarder = settings.tryHarder;
|
||||
|
||||
if (validation.format === "heif") {
|
||||
try {
|
||||
await sharp(fileBuffer).metadata();
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
} catch (err) {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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",
|
||||
if (needsCliDecode(validation.format)) {
|
||||
try {
|
||||
const fileExt = filename.split(".").pop()?.toLowerCase();
|
||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
||||
} catch {
|
||||
try {
|
||||
await sharp(fileBuffer).metadata();
|
||||
} 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 image = sharp(fileBuffer);
|
||||
const metadata = await image.metadata();
|
||||
const width = metadata.width ?? 0;
|
||||
const height = metadata.height ?? 0;
|
||||
const rawData = await image.ensureAlpha().raw().toBuffer();
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
return reply.status(422).send({
|
||||
error: "Could not determine image dimensions",
|
||||
// --- Detect barcodes via zxing-wasm ---
|
||||
const imageData = {
|
||||
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 ---
|
||||
const imageData = {
|
||||
data: new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
|
||||
width,
|
||||
height,
|
||||
} as ImageData;
|
||||
|
||||
const results = await readBarcodes(imageData, {
|
||||
tryHarder,
|
||||
maxNumberOfSymbols: 255,
|
||||
});
|
||||
|
||||
const validResults = results.filter((r) => r.isValid);
|
||||
|
||||
// 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,
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
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({
|
||||
filename,
|
||||
barcodes: [],
|
||||
annotatedUrl: null,
|
||||
previewUrl: null,
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,119 +65,122 @@ registerAiJobHandler("blur-faces", async (input, data, ctx) => {
|
||||
|
||||
/** Face detection and blurring route. */
|
||||
export function registerBlurFaces(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "blur-faces";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
app.post(
|
||||
"/api/v1/tools/image/blur-faces",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "blur-faces";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} 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) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
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 });
|
||||
});
|
||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||
},
|
||||
);
|
||||
|
||||
// Register in the pipeline/batch registry
|
||||
registerToolProcessFn({
|
||||
|
||||
@@ -109,107 +109,110 @@ export function registerEditMetadata(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
app.post(
|
||||
"/api/v1/tools/image/edit-metadata",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(result.error.issues),
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Build ExifTool arguments from settings
|
||||
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);
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
// Determine content type from validated format
|
||||
const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg";
|
||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||
if (!validation.valid) {
|
||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Build ExifTool arguments from settings
|
||||
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 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
|
||||
registerToolProcessFn({
|
||||
|
||||
@@ -58,119 +58,122 @@ registerAiJobHandler("enhance-faces", async (input, data, ctx) => {
|
||||
|
||||
/** Face enhancement route using GFPGAN/CodeFormer. */
|
||||
export function registerEnhanceFaces(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/enhance-faces", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "enhance-faces";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
app.post(
|
||||
"/api/v1/tools/image/enhance-faces",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "enhance-faces";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} 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) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
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 });
|
||||
});
|
||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||
},
|
||||
);
|
||||
|
||||
// Register in the pipeline/batch registry
|
||||
registerToolProcessFn({
|
||||
|
||||
@@ -33,147 +33,150 @@ const settingsSchema = z.object({
|
||||
* via getObjectBuffer(data.inputRefs[1]) inside the handler.
|
||||
*/
|
||||
export function registerEraseObject(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/erase-object", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "erase-object";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
app.post(
|
||||
"/api/v1/tools/image/erase-object",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "erase-object";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let imageBuffer: Buffer | null = null;
|
||||
let maskBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let format = "png";
|
||||
let quality = 95;
|
||||
let imageKey: string | null = null;
|
||||
let maskKey: string | null = null;
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let imageBuffer: Buffer | null = null;
|
||||
let maskBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let format = "png";
|
||||
let quality = 95;
|
||||
let imageKey: string | null = null;
|
||||
let maskKey: string | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
if (part.fieldname === "mask") {
|
||||
const upload = await receiveUpload(part, jobId);
|
||||
maskKey = upload.key;
|
||||
} else {
|
||||
const upload = await receiveUpload(part, jobId);
|
||||
imageKey = upload.key;
|
||||
filename = upload.filename;
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
if (part.fieldname === "mask") {
|
||||
const upload = await receiveUpload(part, jobId);
|
||||
maskKey = upload.key;
|
||||
} else {
|
||||
const upload = await receiveUpload(part, jobId);
|
||||
imageKey = upload.key;
|
||||
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) {
|
||||
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 (!imageKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
if (needsCliDecode(imageValidation.format)) {
|
||||
imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format);
|
||||
if (!maskKey) {
|
||||
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) {
|
||||
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"),
|
||||
|
||||
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)) {
|
||||
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
|
||||
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 });
|
||||
});
|
||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI job handler (separate import for the worker) ───────────────
|
||||
|
||||
@@ -98,46 +98,49 @@ const settingsSchema = z.object({
|
||||
|
||||
export function registerGifTools(app: FastifyInstance) {
|
||||
// ── Metadata endpoint ───────────────────────────────────────────
|
||||
app.post("/api/v1/tools/image/gif-tools/info", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
app.post(
|
||||
"/api/v1/tools/image/gif-tools/info",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
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) {
|
||||
return reply.status(400).send({ error: "No file provided" });
|
||||
}
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No file provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = await sharp(fileBuffer).metadata();
|
||||
const pages = meta.pages ?? 1;
|
||||
const delay = meta.delay ?? Array(pages).fill(100);
|
||||
try {
|
||||
const meta = await sharp(fileBuffer).metadata();
|
||||
const pages = meta.pages ?? 1;
|
||||
const delay = meta.delay ?? Array(pages).fill(100);
|
||||
|
||||
return reply.send({
|
||||
width: meta.width ?? 0,
|
||||
height: meta.pageHeight ?? meta.height ?? 0,
|
||||
pages,
|
||||
delay,
|
||||
loop: meta.loop ?? 0,
|
||||
fileSize: fileBuffer.length,
|
||||
duration: delay.reduce((sum: number, d: number) => sum + d, 0),
|
||||
});
|
||||
} catch {
|
||||
return reply.status(422).send({ error: "Could not read image metadata" });
|
||||
}
|
||||
});
|
||||
return reply.send({
|
||||
width: meta.width ?? 0,
|
||||
height: meta.pageHeight ?? meta.height ?? 0,
|
||||
pages,
|
||||
delay,
|
||||
loop: meta.loop ?? 0,
|
||||
fileSize: fileBuffer.length,
|
||||
duration: delay.reduce((sum: number, d: number) => sum + d, 0),
|
||||
});
|
||||
} catch {
|
||||
return reply.status(422).send({ error: "Could not read image metadata" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Processing endpoint ─────────────────────────────────────────
|
||||
createToolRoute(app, {
|
||||
|
||||
@@ -69,119 +69,122 @@ registerAiJobHandler("noise-removal", async (input, data, ctx) => {
|
||||
* Uses the Python sidecar for multi-tier denoising.
|
||||
*/
|
||||
export function registerNoiseRemoval(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/noise-removal", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "noise-removal";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
app.post(
|
||||
"/api/v1/tools/image/noise-removal",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const toolId = "noise-removal";
|
||||
if (!isToolInstalled(toolId)) {
|
||||
const bundle = getBundleForTool(toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: TOOL_BUNDLE_MAP[toolId],
|
||||
featureName: bundle?.name ?? toolId,
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} 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) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
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 });
|
||||
});
|
||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||
},
|
||||
);
|
||||
|
||||
// Register in the pipeline/batch registry
|
||||
registerToolProcessFn({
|
||||
|
||||
@@ -30,83 +30,86 @@ const settingsSchema = z.object({
|
||||
* images from text input, not from uploaded files.
|
||||
*/
|
||||
export function registerQrGenerate(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/qr-generate", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
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();
|
||||
app.post(
|
||||
"/api/v1/tools/image/qr-generate",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = request.body;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid request body" });
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
const filename = "qrcode.png";
|
||||
await putObject(`outputs/${jobId}/${filename}`, buffer);
|
||||
const result = settingsSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
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 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",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,125 +83,128 @@ registerAiJobHandler("restore-photo", async (input, data, ctx) => {
|
||||
* optional colorization.
|
||||
*/
|
||||
export function registerRestorePhoto(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/image/restore-photo", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!isToolInstalled("restore-photo")) {
|
||||
const bundle = getBundleForTool("restore-photo");
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: "photo-restoration",
|
||||
featureName: bundle?.name ?? "Photo Restoration",
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
app.post(
|
||||
"/api/v1/tools/image/restore-photo",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!isToolInstalled("restore-photo")) {
|
||||
const bundle = getBundleForTool("restore-photo");
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: "photo-restoration",
|
||||
featureName: bundle?.name ?? "Photo Restoration",
|
||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
const userId = getAuthUser(request)?.id ?? null;
|
||||
const jobId = randomUUID();
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} 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) {
|
||||
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) });
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
if (validation.format === "heif") {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
fileBuffer = await getObjectBuffer(inputKey);
|
||||
|
||||
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);
|
||||
}
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
if (validation.format === "avif") {
|
||||
try {
|
||||
fileBuffer = await sharp(fileBuffer).png().toBuffer();
|
||||
} catch {
|
||||
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
|
||||
|
||||
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" });
|
||||
}
|
||||
} 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"),
|
||||
|
||||
try {
|
||||
if (validation.format === "heif") {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
}
|
||||
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}`;
|
||||
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 });
|
||||
});
|
||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||
},
|
||||
);
|
||||
|
||||
// Register in the pipeline/batch registry
|
||||
registerToolProcessFn({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
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 Navbar from "@/components/Navbar.astro";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
export function getStaticPaths() {
|
||||
return SECTIONS.map((s) => ({
|
||||
|
||||
Reference in New Issue
Block a user