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. 60
      src/app/api/save-generation/route.ts

60
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<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
export async function POST(request: NextRequest) {
let directoryPath: string | undefined;
@ -108,12 +132,29 @@ 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);
// 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)
@ -122,8 +163,7 @@ export async function POST(request: NextRequest) {
.replace(/^_|_$/g, "")
.toLowerCase()
: "generation";
filename = `${timestamp}_${promptSnippet}.${extension}`;
}
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', {

Loading…
Cancel
Save