feat: compression 7

This commit is contained in:
jfmcarreira committed 2026-09-24 14:51:21 +01:00
1 parent 1b63992d0c
commit 5817752bc4
7 files changed
+52 -24

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ export interface ICompressionResult {
assetId: string;
originalSize: number;
compressedSize: number;
outputFileName?: string;
resultUrl: string;
}
+34 -4
View File
@@ -14,11 +14,41 @@ const COMPRESSION_ROOT = "/app/data/compression";
export const getCompressionDirectory = (assetId: string) =>
path.join(COMPRESSION_ROOT, assetId);
export const getCompressionInputPath = (assetId: string) =>
path.join(getCompressionDirectory(assetId), "input");
const getSafeOriginalFileName = (assetId: string, originalFileName: string) => {
const fileName = path.basename(originalFileName || "");
return fileName || `${assetId}.mp4`;
};
export const getCompressionOutputPath = (assetId: string) =>
path.join(getCompressionDirectory(assetId), "output.mp4");
export const getCompressionInputPath = (assetId: string, originalFileName: string) =>
path.join(getCompressionDirectory(assetId), getSafeOriginalFileName(assetId, originalFileName));
export const getCompressionOutputFileName = (assetId: string, originalFileName: string) => {
const inputFileName = getSafeOriginalFileName(assetId, originalFileName);
const extension = path.extname(inputFileName);
const baseName = extension
? inputFileName.slice(0, -extension.length)
: inputFileName;
return `${baseName}-compressed${extension || ".mp4"}`;
};
export const getCompressionOutputPath = (assetId: string, originalFileName: string) =>
path.join(
getCompressionDirectory(assetId),
getCompressionOutputFileName(assetId, originalFileName)
);
export const getVideoContentType = (fileName: string) => {
switch (path.extname(fileName).toLowerCase()) {
case ".mov": return "video/quicktime";
case ".mkv": return "video/x-matroska";
case ".webm": return "video/webm";
case ".avi": return "video/x-msvideo";
case ".m4v": return "video/x-m4v";
case ".mp4": return "video/mp4";
default: return "application/octet-stream";
}
};
export const ensureCompressionDirectory = async (assetId: string) => {
await mkdir(getCompressionDirectory(assetId), { recursive: true });
+5 -11
View File
@@ -2,7 +2,7 @@ import { ENV } from "@/config/environment";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { getUserHeaders } from "@/helpers/user.helper";
import { clearCompressionDirectory, getCompressionOutputPath } from "@/helpers/compression.server";
import { clearCompressionDirectory, getCompressionOutputPath, getVideoContentType } from "@/helpers/compression.server";
import { assets } from "@/schema";
import { and, eq } from "drizzle-orm";
import { openAsBlob } from "node:fs";
@@ -22,7 +22,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const rawAssetId = req.body?.assetId as string;
const [dbAsset] = await db
.select({ id: assets.id, type: assets.type })
.select({ id: assets.id, type: assets.type, originalFileName: assets.originalFileName })
.from(assets)
.where(and(eq(assets.id, rawAssetId), eq(assets.ownerId, currentUser.id)));
@@ -63,15 +63,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const customMetadata = metadataResponse.ok ? await metadataResponse.json() : [];
const originalExtension = path.extname(source.originalFileName || "");
const originalBase = originalExtension
? path.basename(source.originalFileName, originalExtension)
: (source.originalFileName || dbAsset.id);
const replacementFileName = originalExtension.toLowerCase() === ".mp4"
? source.originalFileName
: `${originalBase}.mp4`;
const fileBlob = await openAsBlob(getCompressionOutputPath(dbAsset.id), { type: "video/mp4" });
const replacementFileName = path.basename(source.originalFileName || dbAsset.originalFileName || `${dbAsset.id}.mp4`);
const outputPath = getCompressionOutputPath(dbAsset.id, dbAsset.originalFileName);
const fileBlob = await openAsBlob(outputPath, { type: getVideoContentType(outputPath) });
const formData = new FormData();
formData.set("fileCreatedAt", source.fileCreatedAt);
formData.set("fileModifiedAt", source.fileModifiedAt);
@@ -1,6 +1,6 @@
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { clearCompressionDirectory, getCompressionOutputPath } from "@/helpers/compression.server";
import { clearCompressionDirectory, getCompressionOutputPath, getVideoContentType } from "@/helpers/compression.server";
import { assets } from "@/schema";
import { and, eq } from "drizzle-orm";
import { createReadStream } from "node:fs";
@@ -13,7 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const rawAssetId = req.query.assetId as string;
const [asset] = await db
.select({ id: assets.id })
.select({ id: assets.id, originalFileName: assets.originalFileName })
.from(assets)
.where(and(eq(assets.id, rawAssetId), eq(assets.ownerId, currentUser.id)));
@@ -26,13 +26,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
const filePath = getCompressionOutputPath(asset.id);
const filePath = getCompressionOutputPath(asset.id, asset.originalFileName);
try {
const fileStat = await stat(filePath);
const range = req.headers.range;
res.setHeader("Content-Type", "video/mp4");
res.setHeader("Content-Type", getVideoContentType(filePath));
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Cache-Control", "no-store");
+3 -2
View File
@@ -1,6 +1,6 @@
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { getCompressionOutputPath } from "@/helpers/compression.server";
import { getCompressionOutputFileName, getCompressionOutputPath } from "@/helpers/compression.server";
import { assets, exif } from "@/schema";
import { and, eq, inArray } from "drizzle-orm";
import { readdir, stat } from "node:fs/promises";
@@ -37,13 +37,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const results = await Promise.all(rows.map(async (asset) => {
try {
const outputPath = getCompressionOutputPath(asset.id);
const outputPath = getCompressionOutputPath(asset.id, asset.originalFileName);
const outputStat = await stat(outputPath);
if (!outputStat.isFile()) return null;
return {
assetId: asset.id,
originalFileName: asset.originalFileName,
outputFileName: getCompressionOutputFileName(asset.id, asset.originalFileName),
localDateTime: asset.localDateTime,
originalSize: Number(asset.originalSize ?? 0),
compressedSize: outputStat.size,
@@ -27,6 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
.select({
id: assets.id,
type: assets.type,
originalFileName: assets.originalFileName,
})
.from(assets)
.where(and(eq(assets.id, assetId), eq(assets.ownerId, currentUser.id)));
@@ -35,8 +36,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(404).json({ message: "Video not found" });
}
const inputPath = getCompressionInputPath(asset.id);
const outputPath = getCompressionOutputPath(asset.id);
const inputPath = getCompressionInputPath(asset.id, asset.originalFileName);
const outputPath = getCompressionOutputPath(asset.id, asset.originalFileName);
try {
await clearCompressionDirectory(asset.id);
@@ -65,6 +66,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
assetId: asset.id,
originalSize: originalStat.size,
compressedSize: outputStat.size,
outputFileName: outputPath.split("/").pop(),
resultUrl: `/api/assets/compression/result/${asset.id}`,
});
} catch (error: any) {
+1 -1
View File
@@ -432,7 +432,7 @@ export default function CompressVideosPage() {
<div key={item.assetId} className="border-b last:border-b-0 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.originalFileName}</div>
<div className="truncate text-sm font-medium">{item.outputFileName ?? item.originalFileName}</div>
<div className="text-xs text-muted-foreground">
{formatMB(item.originalSize)} → {formatMB(item.compressedSize)} · saved {formatMB(savedBytes)} ({savedPercent}%)
</div>