From 0e496bc8557b6a6fb0e8f4973111ce597dab0509 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 12 Jan 2026 23:04:37 +1300 Subject: [PATCH] feat(13-01): add content hashing to save-generation API - Add MD5 hashing of buffer content for deduplication - Check for existing file by hash prefix before saving - Use hash-prefixed filenames: {hash}_{prompt}.{ext} - Add isDuplicate field to response JSON - Log deduplication events for observability --- src/app/api/save-generation/route.ts | 74 ++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/src/app/api/save-generation/route.ts b/src/app/api/save-generation/route.ts index 7234dc21..e2df53f4 100644 --- a/src/app/api/save-generation/route.ts +++ b/src/app/api/save-generation/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import * as fs from "fs/promises"; import * as path from "path"; +import * as crypto from "crypto"; import { logger } from "@/utils/logger"; // Helper to get file extension from MIME type @@ -22,6 +23,29 @@ function isHttpUrl(str: string): boolean { return str.startsWith("http://") || str.startsWith("https://"); } +// Helper to compute MD5 hash of buffer content +function computeContentHash(buffer: Buffer): string { + return crypto.createHash("md5").update(buffer).digest("hex"); +} + +// Helper to find existing file by hash prefix +async function findExistingFileByHash( + directoryPath: string, + hash: string, + extension: string +): Promise { + try { + const files = await fs.readdir(directoryPath); + // Look for files starting with this hash + const matching = files.find( + (f) => f.startsWith(hash) && f.endsWith(`.${extension}`) + ); + return matching || null; + } catch { + return null; + } +} + // POST: Save a generated image or video to the generations folder export async function POST(request: NextRequest) { let directoryPath: string | undefined; @@ -108,22 +132,38 @@ export async function POST(request: NextRequest) { } } - // Generate filename - let filename: string; - if (imageId) { - filename = `${imageId}.${extension}`; - } else { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const promptSnippet = prompt - ? prompt - .slice(0, 30) - .replace(/[^a-zA-Z0-9]/g, "_") - .replace(/_+/g, "_") - .replace(/^_|_$/g, "") - .toLowerCase() - : "generation"; - filename = `${timestamp}_${promptSnippet}.${extension}`; + // Compute content hash for deduplication + const contentHash = computeContentHash(buffer); + + // Check for existing file with same hash (deduplication) + const existingFile = await findExistingFileByHash(directoryPath, contentHash, extension); + if (existingFile) { + const existingPath = path.join(directoryPath, existingFile); + logger.info('file.save', 'Generation deduplicated: existing file found', { + contentHash, + existingFile, + filePath: existingPath, + }); + + return NextResponse.json({ + success: true, + filePath: existingPath, + filename: existingFile, + imageId: existingFile.replace(`.${extension}`, ''), + isDuplicate: true, + }); } + + // Generate filename with hash prefix for fast future lookups + const promptSnippet = prompt + ? prompt + .slice(0, 30) + .replace(/[^a-zA-Z0-9]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .toLowerCase() + : "generation"; + const filename = `${contentHash}_${promptSnippet}.${extension}`; const filePath = path.join(directoryPath, filename); // Write the file @@ -134,13 +174,15 @@ export async function POST(request: NextRequest) { filename, fileSize: buffer.length, isVideo, + contentHash, }); return NextResponse.json({ success: true, filePath, filename, - imageId: imageId || filename.replace(`.${extension}`, ''), + imageId: filename.replace(`.${extension}`, ''), + isDuplicate: false, }); } catch (error) { logger.error('file.error', 'Failed to save generation', {