hp-prod-tracker/src/lib/services/upload-service.ts
DJP 277ad85073 Prepend basePath to stored media URLs so assets load under /hp-prod-tracker
upload-service.ts and annotation-service.ts were storing URLs like
/api/uploads/revisions/... in the database. When the app is served at
/hp-prod-tracker, the browser needs /hp-prod-tracker/api/uploads/...
to hit the correct route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 07:47:08 -04:00

344 lines
11 KiB
TypeScript

import { writeFile, mkdir, unlink, readdir, rm } from "fs/promises";
import { createWriteStream, existsSync } from "fs";
import path from "path";
import sharp from "sharp";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { prisma } from "@/lib/prisma";
import {
extractMetadata,
extractThumbnail,
transcodeToHLS,
} from "@/lib/services/video-service";
/** Base path prefix so stored URLs work when the app is mounted at a sub-path */
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
/** Images stored alongside videos in the mounted volume, served via /api/uploads */
const UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR
? path.join(process.env.VIDEO_UPLOADS_DIR, "..", "revisions")
: (process.env.NODE_ENV === "production"
? "/data/uploads/revisions"
: path.join(process.cwd(), "data", "uploads", "revisions"));
/** Videos go to a mounted volume, served via /api/uploads streaming route */
const VIDEO_UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR ||
(process.env.NODE_ENV === "production"
? "/data/uploads/revisions"
: path.join(process.cwd(), "data", "uploads", "revisions"));
const ALLOWED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/tiff",
];
const ALLOWED_VIDEO_TYPES = ["video/mp4"];
const MAX_IMAGE_SIZE = 50 * 1024 * 1024; // 50MB
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500MB
export interface UploadedImage {
url: string;
filename: string;
size: number;
width: number;
height: number;
uploadedAt: string;
originalUrl?: string; // kept for transparent PNGs
}
/**
* Process and store an uploaded image for a revision.
*
* - Validates file type and size
* - For PNGs with alpha: flattens onto white background (CG renders
* have semi-transparent drop shadows that break comparison modes)
* - Stores the processed image and returns metadata
*/
export async function processAndStoreImage(
revisionId: string,
file: File,
imageType: "reference" | "current" | "screenshot"
): Promise<UploadedImage> {
// Validate type
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
throw new Error(
`Unsupported file type: ${file.type}. Allowed: PNG, JPEG, WebP, TIFF.`
);
}
// Validate size
if (file.size > MAX_IMAGE_SIZE) {
throw new Error(
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 50MB.`
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const metadata = await sharp(buffer).metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Could not read image dimensions.");
}
// Ensure upload directory exists
const revisionDir = path.join(UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) {
await mkdir(revisionDir, { recursive: true });
}
const timestamp = Date.now();
const ext = file.type === "image/tiff" ? "tiff" : "png";
const filename = `${imageType}_${timestamp}.${ext}`;
const filePath = path.join(revisionDir, filename);
const url = `${BASE_PATH}/api/uploads/revisions/${revisionId}/${filename}`;
let originalUrl: string | undefined;
// PNG alpha compositing: flatten transparent PNGs onto white
if (metadata.format === "png" && metadata.hasAlpha) {
// Save original for download
const originalFilename = `${imageType}_${timestamp}_original.png`;
const originalPath = path.join(revisionDir, originalFilename);
await writeFile(originalPath, buffer);
originalUrl = `${BASE_PATH}/api/uploads/revisions/${revisionId}/${originalFilename}`;
// Flatten onto white background
const flattened = await sharp(buffer)
.flatten({ background: { r: 255, g: 255, b: 255 } })
.png()
.toBuffer();
await writeFile(filePath, flattened);
} else if (metadata.format === "tiff") {
// Convert TIFF to PNG for browser display, keep original
const originalFilename = `${imageType}_${timestamp}_original.tiff`;
const originalPath = path.join(revisionDir, originalFilename);
await writeFile(originalPath, buffer);
originalUrl = `${BASE_PATH}/api/uploads/revisions/${revisionId}/${originalFilename}`;
const converted = await sharp(buffer).png().toBuffer();
const pngFilename = `${imageType}_${timestamp}.png`;
const pngPath = path.join(revisionDir, pngFilename);
await writeFile(pngPath, converted);
// URL already points to png
} else {
// JPEG/WebP/PNG without alpha: store as-is
await writeFile(filePath, buffer);
}
// Generate thumbnail for gallery
const thumbFilename = `${imageType}_${timestamp}_thumb.jpg`;
const thumbPath = path.join(revisionDir, thumbFilename);
await sharp(buffer)
.resize(200, 200, { fit: "inside", withoutEnlargement: true })
.flatten({ background: { r: 255, g: 255, b: 255 } })
.jpeg({ quality: 80 })
.toBuffer()
.then((thumbBuffer) => writeFile(thumbPath, thumbBuffer));
return {
url,
filename: file.name,
size: file.size,
width: metadata.width,
height: metadata.height,
uploadedAt: new Date().toISOString(),
originalUrl,
};
}
/**
* Delete uploaded images for a revision image type.
*/
export async function deleteRevisionImage(
revisionId: string,
imageType: "reference" | "current"
): Promise<void> {
const revisionDir = path.join(UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) return;
const files = await readdir(revisionDir);
for (const file of files) {
if (file.startsWith(`${imageType}_`)) {
await unlink(path.join(revisionDir, file)).catch(() => {});
}
}
}
// ─── Video Upload ──────────────────────────────────────────────
export type VideoType = "video" | "referenceVideo";
export interface UploadedVideo {
url: string;
hlsUrl: string | null;
status: "processing" | "ready" | "failed";
thumbnailUrl: string | null;
filename: string;
size: number;
width: number;
height: number;
duration: number;
fps: number;
codec: string;
uploadedAt: string;
}
/**
* Process and store an uploaded video for a revision.
*
* Flow:
* 1. Validate type & size
* 2. Stream-write raw MP4 to disk (avoids buffering 500MB in memory)
* 3. Extract metadata via ffprobe (instant — headers only)
* 4. Extract thumbnail JPEG
* 5. Return immediately with status: "processing"
* 6. Kick off async HLS transcoding, update revision on completion
*/
export async function processAndStoreVideo(
revisionId: string,
file: File,
videoType: VideoType
): Promise<UploadedVideo> {
// Validate MIME type
if (!ALLOWED_VIDEO_TYPES.includes(file.type)) {
throw new Error(
`Unsupported file type: ${file.type}. Allowed: MP4.`
);
}
// Validate size
if (file.size > MAX_VIDEO_SIZE) {
throw new Error(
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 500MB.`
);
}
// Ensure upload directory
const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) {
await mkdir(revisionDir, { recursive: true });
}
const timestamp = Date.now();
const videoFilename = `${videoType}_${timestamp}.mp4`;
const videoPath = path.join(revisionDir, videoFilename);
const videoUrl = `${BASE_PATH}/api/uploads/revisions/${revisionId}/${videoFilename}`;
// Stream-write the file to disk
const arrayBuffer = await file.arrayBuffer();
const nodeStream = Readable.from(Buffer.from(arrayBuffer));
await pipeline(nodeStream, createWriteStream(videoPath));
// Extract metadata (reads headers — fast; returns defaults if no FFmpeg)
const metadata = await extractMetadata(videoPath);
// Extract thumbnail (skipped if no FFmpeg)
const thumbFilename = `${videoType}_${timestamp}_thumb.jpg`;
const thumbPath = path.join(revisionDir, thumbFilename);
const thumbTime = Math.min(1, metadata.duration * 0.1 || 1);
const thumbCreated = await extractThumbnail(videoPath, thumbPath, thumbTime);
const thumbnailUrl = thumbCreated
? `${BASE_PATH}/api/uploads/revisions/${revisionId}/${thumbFilename}`
: null;
// HLS output paths
const hlsDir = path.join(revisionDir, `${videoType}_${timestamp}_hls`);
const hlsUrl = `${BASE_PATH}/api/uploads/revisions/${revisionId}/${videoType}_${timestamp}_hls/index.m3u8`;
const uploaded: UploadedVideo = {
url: videoUrl,
hlsUrl: null,
status: "processing",
thumbnailUrl,
filename: file.name,
size: file.size,
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
fps: metadata.fps,
codec: metadata.codec,
uploadedAt: new Date().toISOString(),
};
// Kick off async HLS transcoding — don't await
transcodeToHLS(videoPath, hlsDir)
.then(async (result) => {
if (!result) {
console.log(`[Video] FFmpeg unavailable — serving raw MP4 for ${revisionId}/${videoType}`);
}
// Atomically read-modify-write attachments to avoid race with concurrent uploads
await updateVideoAttachment(revisionId, videoType, (existing) => ({
...existing,
hlsUrl: result ? hlsUrl : null,
status: "ready",
}));
console.log(`[Video] Processing complete for ${revisionId}/${videoType}${result ? " (HLS)" : " (MP4 only)"}`);
})
.catch(async (err) => {
console.error(`[Video] HLS transcoding failed for ${revisionId}:`, err);
await updateVideoAttachment(revisionId, videoType, (existing) => ({
...existing,
status: "failed",
})).catch(() => {});
});
return uploaded;
}
/**
* Atomically update a video attachment within a revision's JSON attachments.
* Uses a transaction to avoid race conditions when two uploads (video + referenceVideo)
* modify the same revision concurrently.
*/
async function updateVideoAttachment(
revisionId: string,
videoType: VideoType,
updater: (existing: Record<string, unknown>) => Record<string, unknown>
) {
await prisma.$transaction(async (tx) => {
const revision = await tx.revision.findUnique({
where: { id: revisionId },
select: { attachments: true },
});
if (!revision) return;
const attachments = (revision.attachments as Record<string, unknown>) ?? {};
const videoKey = videoType === "video" ? "video" : "referenceVideo";
const existing = attachments[videoKey] as Record<string, unknown> | undefined;
if (!existing) return;
attachments[videoKey] = updater(existing);
await tx.revision.update({
where: { id: revisionId },
data: { attachments: attachments as any },
});
});
}
/**
* Delete uploaded video files and HLS segments for a revision.
*/
export async function deleteRevisionVideo(
revisionId: string,
videoType: VideoType
): Promise<void> {
const revisionDir = path.join(VIDEO_UPLOADS_DIR, revisionId);
if (!existsSync(revisionDir)) return;
const files = await readdir(revisionDir);
for (const file of files) {
if (file.startsWith(`${videoType}_`)) {
const fullPath = path.join(revisionDir, file);
// Could be a directory (HLS segments) or a file
await rm(fullPath, { recursive: true, force: true }).catch(() => {});
}
}
}