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>
344 lines
11 KiB
TypeScript
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(() => {});
|
|
}
|
|
}
|
|
}
|