mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
217 lines
6.8 KiB
TypeScript
217 lines
6.8 KiB
TypeScript
/**
|
|
* Post-processing helpers shared by the inline tool factory and the BullMQ
|
|
* worker runtime. Extracted from tool-factory.ts so both code paths use
|
|
* the same logic without duplication.
|
|
*
|
|
* generatePreview writes to object storage (for the worker). The factory
|
|
* keeps its own workspace-based preview write until Task 8 converts it.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import { extname } from "node:path";
|
|
import { eq } from "drizzle-orm";
|
|
import sharp from "sharp";
|
|
import { db, schema } from "../db/index.js";
|
|
import { putObject } from "../lib/object-storage.js";
|
|
import { pdfFirstPagePreview, videoPosterPreview } from "../modality/preview.js";
|
|
|
|
// ── Content-type to extension map ──────────────────────────────
|
|
|
|
export const CONTENT_TYPE_TO_EXT: Record<string, string> = {
|
|
"image/jpeg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/webp": ".webp",
|
|
"image/gif": ".gif",
|
|
"image/tiff": ".tiff",
|
|
"image/avif": ".avif",
|
|
"image/svg+xml": ".svg",
|
|
"image/bmp": ".bmp",
|
|
"image/heic": ".heic",
|
|
"image/heif": ".heif",
|
|
"image/jxl": ".jxl",
|
|
"image/x-icon": ".ico",
|
|
"image/vnd.adobe.photoshop": ".psd",
|
|
"image/x-exr": ".exr",
|
|
"image/vnd.radiance": ".hdr",
|
|
"image/x-targa": ".tga",
|
|
"image/jp2": ".jp2",
|
|
"image/qoi": ".qoi",
|
|
"application/postscript": ".eps",
|
|
"image/vnd.ms-dds": ".dds",
|
|
"image/x-dpx": ".dpx",
|
|
"image/fits": ".fits",
|
|
};
|
|
|
|
// ── Build output filename ──────────────────────────────────────
|
|
|
|
/**
|
|
* Build the output filename: add a tool-specific `_toolId` suffix when
|
|
* the tool did not rename the file, then fix the extension when the
|
|
* output content-type differs from the original extension.
|
|
*/
|
|
export function buildOutputName(
|
|
resultFilename: string,
|
|
originalFilename: string,
|
|
toolId: string,
|
|
contentType: string,
|
|
): string {
|
|
let out = resultFilename;
|
|
|
|
// Add tool suffix only when the tool did not change the filename
|
|
if (out === originalFilename) {
|
|
const ext = extname(originalFilename);
|
|
const base = ext ? originalFilename.slice(0, -ext.length) : originalFilename;
|
|
out = `${base}_${toolId}${ext}`;
|
|
}
|
|
|
|
// Fix extension mismatch (e.g. SVG input -> PNG output)
|
|
const expectedExt = CONTENT_TYPE_TO_EXT[contentType];
|
|
if (expectedExt) {
|
|
const currentExt = extname(out).toLowerCase();
|
|
if (currentExt && currentExt !== expectedExt) {
|
|
out = out.slice(0, -currentExt.length) + expectedExt;
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
// ── Generate preview (object-storage backed) ───────────────────
|
|
|
|
const BROWSER_PREVIEWABLE = new Set([
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/svg+xml",
|
|
"image/bmp",
|
|
"image/avif",
|
|
]);
|
|
|
|
/**
|
|
* Generate a browser-previewable thumbnail for formats that browsers
|
|
* cannot render in <img> tags. Writes to object storage under
|
|
* `outputs/<jobId>/preview.<ext>` (webp for images/video, png for PDF).
|
|
*
|
|
* Returns the object key on success, undefined when the format is already
|
|
* previewable or when generation fails (non-fatal).
|
|
*/
|
|
export async function generatePreview(
|
|
buffer: Buffer,
|
|
contentType: string,
|
|
jobId: string,
|
|
fallbackInput?: Buffer,
|
|
): Promise<string | undefined> {
|
|
// Per-modality dispatch (before the image logic)
|
|
if (contentType.startsWith("video/")) {
|
|
const poster = await videoPosterPreview(buffer);
|
|
if (!poster) return undefined;
|
|
const key = `outputs/${jobId}/preview.webp`;
|
|
await putObject(key, poster);
|
|
return key;
|
|
}
|
|
if (contentType.startsWith("audio/")) return undefined; // no preview (spec 4.5)
|
|
if (contentType === "application/pdf") {
|
|
const page = await pdfFirstPagePreview(buffer);
|
|
if (!page) return undefined;
|
|
const key = `outputs/${jobId}/preview.png`;
|
|
await putObject(key, page);
|
|
return key;
|
|
}
|
|
|
|
// Image logic unchanged below
|
|
if (BROWSER_PREVIEWABLE.has(contentType)) return undefined;
|
|
|
|
const key = `outputs/${jobId}/preview.webp`;
|
|
|
|
try {
|
|
let previewInput = buffer;
|
|
// Sharp cannot decode HEIC natively; use system decoder first
|
|
if (contentType === "image/heic" || contentType === "image/heif") {
|
|
const { decodeHeic } = await import("../lib/heic-converter.js");
|
|
previewInput = await decodeHeic(buffer);
|
|
}
|
|
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
|
|
await putObject(key, previewBuffer);
|
|
return key;
|
|
} catch {
|
|
// Retry with the original input buffer (pre-processing) which was
|
|
// already validated and decoded during the intake phase.
|
|
if (fallbackInput) {
|
|
try {
|
|
const fallbackBuffer = await sharp(fallbackInput).webp({ quality: 80 }).toBuffer();
|
|
await putObject(key, fallbackBuffer);
|
|
return key;
|
|
} catch {
|
|
// Both attempts failed; frontend will use the upload preview
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ── Auto-save to user file library ─────────────────────────────
|
|
|
|
export interface AutoSaveOpts {
|
|
fileId?: string;
|
|
userId: string | null;
|
|
buffer: Buffer;
|
|
outName: string;
|
|
contentType: string;
|
|
toolId: string;
|
|
}
|
|
|
|
/**
|
|
* Auto-save a processed output to the persistent user file library when a
|
|
* fileId is provided. Creates a new version linked to the parent file with
|
|
* the tool appended to the toolChain.
|
|
*
|
|
* Returns the new file ID on success, undefined when no fileId or on error.
|
|
*/
|
|
export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise<string | undefined> {
|
|
if (!opts.fileId) return undefined;
|
|
|
|
try {
|
|
const { saveFile } = await import("../lib/file-storage.js");
|
|
const [parent] = await db
|
|
.select()
|
|
.from(schema.userFiles)
|
|
.where(eq(schema.userFiles.id, opts.fileId));
|
|
if (!parent) return undefined;
|
|
|
|
const newVersion = parent.version + 1;
|
|
const parentChain: string[] = parent.toolChain ?? [];
|
|
const newToolChain = [...parentChain, opts.toolId];
|
|
const storedName = await saveFile(opts.buffer, opts.outName);
|
|
|
|
// Get image dimensions from the processed output
|
|
let width: number | null = null;
|
|
let height: number | null = null;
|
|
try {
|
|
const meta = await sharp(opts.buffer).metadata();
|
|
width = meta.width ?? null;
|
|
height = meta.height ?? null;
|
|
} catch {
|
|
// dimensions are non-critical
|
|
}
|
|
|
|
const newId = randomUUID();
|
|
await db.insert(schema.userFiles).values({
|
|
id: newId,
|
|
userId: parent.userId,
|
|
originalName: opts.outName,
|
|
storedName,
|
|
mimeType: opts.contentType,
|
|
size: opts.buffer.length,
|
|
width,
|
|
height,
|
|
version: newVersion,
|
|
parentId: opts.fileId,
|
|
toolChain: newToolChain,
|
|
});
|
|
return newId;
|
|
} catch {
|
|
// Non-fatal: tool processing already succeeded
|
|
return undefined;
|
|
}
|
|
}
|