Browse Source

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
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
0e496bc855
  1. 74
      src/app/api/save-generation/route.ts

74
src/app/api/save-generation/route.ts

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import * as path from "path"; import * as path from "path";
import * as crypto from "crypto";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
// Helper to get file extension from MIME type // Helper to get file extension from MIME type
@ -22,6 +23,29 @@ function isHttpUrl(str: string): boolean {
return str.startsWith("http://") || str.startsWith("https://"); 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<string | null> {
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 // POST: Save a generated image or video to the generations folder
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let directoryPath: string | undefined; let directoryPath: string | undefined;
@ -108,22 +132,38 @@ export async function POST(request: NextRequest) {
} }
} }
// Generate filename // Compute content hash for deduplication
let filename: string; const contentHash = computeContentHash(buffer);
if (imageId) {
filename = `${imageId}.${extension}`; // Check for existing file with same hash (deduplication)
} else { const existingFile = await findExistingFileByHash(directoryPath, contentHash, extension);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); if (existingFile) {
const promptSnippet = prompt const existingPath = path.join(directoryPath, existingFile);
? prompt logger.info('file.save', 'Generation deduplicated: existing file found', {
.slice(0, 30) contentHash,
.replace(/[^a-zA-Z0-9]/g, "_") existingFile,
.replace(/_+/g, "_") filePath: existingPath,
.replace(/^_|_$/g, "") });
.toLowerCase()
: "generation"; return NextResponse.json({
filename = `${timestamp}_${promptSnippet}.${extension}`; 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); const filePath = path.join(directoryPath, filename);
// Write the file // Write the file
@ -134,13 +174,15 @@ export async function POST(request: NextRequest) {
filename, filename,
fileSize: buffer.length, fileSize: buffer.length,
isVideo, isVideo,
contentHash,
}); });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
filePath, filePath,
filename, filename,
imageId: imageId || filename.replace(`.${extension}`, ''), imageId: filename.replace(`.${extension}`, ''),
isDuplicate: false,
}); });
} catch (error) { } catch (error) {
logger.error('file.error', 'Failed to save generation', { logger.error('file.error', 'Failed to save generation', {

Loading…
Cancel
Save