fix: image transforms via Sharp, model photos crash, video duration
All checks were successful
Build and Push Backend Image / build (push) Successful in 46s
Build and Push Frontend Image / build (push) Successful in 5m7s

- Backend: add Sharp image transform endpoint (/assets/:id?transform=X)
  with presets: mini(64), thumbnail(200), preview(480), medium(960), banner(1280)
  Transformed images are cached as webp next to originals
- Frontend: fix model photos crash (p.directus_files_id → p)
- Frontend: fix model banner URL (data.model.banner.id → data.model.banner)
- Frontend: fix video duration display (video.movie.duration → video.movie_file?.duration)
  across models/[slug], videos, videos/[slug], and home pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 20:56:33 +01:00
parent 273aa42510
commit 05cb6a66e3
8 changed files with 321 additions and 18 deletions

View File

@@ -31,6 +31,7 @@
"nanoid": "^3.3.11",
"nodemailer": "^7.0.3",
"pg": "^8.16.0",
"sharp": "^0.33.5",
"slugify": "^1.6.6",
"uuid": "^11.1.0"
},
@@ -38,6 +39,7 @@
"@types/fluent-ffmpeg": "^2.1.27",
"@types/nodemailer": "^6.4.17",
"@types/pg": "^8.15.4",
"@types/sharp": "^0.32.0",
"@types/uuid": "^10.0.0",
"drizzle-kit": "^0.31.1",
"tsx": "^4.19.4",

View File

@@ -7,6 +7,8 @@ import { createYoga } from "graphql-yoga";
import { eq } from "drizzle-orm";
import { files } from "./db/schema/index";
import path from "path";
import { existsSync } from "fs";
import sharp from "sharp";
import { schema } from "./graphql/index";
import { buildContext } from "./graphql/context";
import { db } from "./db/connection";
@@ -67,10 +69,20 @@ async function main() {
yoga.handleNodeRequestAndResponse(req, reply, { req, reply, db, redis }),
});
// Serve uploaded files: GET /assets/:id
// Transform presets: width x height (height optional = keep aspect ratio)
const TRANSFORMS: Record<string, { width: number; height?: number }> = {
mini: { width: 64, height: 64 },
thumbnail: { width: 200, height: 200 },
preview: { width: 480, height: 270 },
medium: { width: 960 },
banner: { width: 1280, height: 400 },
};
// Serve uploaded files: GET /assets/:id?transform=<preset>
// Files are stored as <UPLOAD_DIR>/<id>/<filename> — look up filename in DB
fastify.get("/assets/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const { transform } = request.query as { transform?: string };
const result = await db
.select({ filename: files.filename, mime_type: files.mime_type })
@@ -81,8 +93,23 @@ async function main() {
if (!result[0]) return reply.status(404).send({ error: "File not found" });
const { filename, mime_type } = result[0];
reply.header("Content-Type", mime_type);
reply.header("Cache-Control", "public, max-age=31536000, immutable");
const preset = transform ? TRANSFORMS[transform] : null;
if (preset && mime_type?.startsWith("image/")) {
const cacheFile = path.join(UPLOAD_DIR, id, `${transform}.webp`);
if (!existsSync(cacheFile)) {
const originalPath = path.join(UPLOAD_DIR, id, filename);
await sharp(originalPath)
.resize({ width: preset.width, height: preset.height, fit: "cover", withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(cacheFile);
}
reply.header("Content-Type", "image/webp");
return reply.sendFile(path.join(id, `${transform}.webp`));
}
reply.header("Content-Type", mime_type);
return reply.sendFile(path.join(id, filename));
});