fix: image transforms via Sharp, model photos crash, video duration
- 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:
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ const { data } = $props();
|
||||
<div
|
||||
class="absolute bottom-2 left-2 text-white text-sm font-medium"
|
||||
>
|
||||
{formatVideoDuration(video.movie.duration)}
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||
</div>
|
||||
<!-- <div
|
||||
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
|
||||
|
||||
@@ -22,9 +22,9 @@ const { data } = $props();
|
||||
|
||||
let images = $derived(
|
||||
data.model.photos.map((p) => ({
|
||||
...p.directus_files_id,
|
||||
url: getAssetUrl(p.directus_files_id.id),
|
||||
thumbnail: getAssetUrl(p.directus_files_id.id, "thumbnail"),
|
||||
...p,
|
||||
url: getAssetUrl(p.id),
|
||||
thumbnail: getAssetUrl(p.id, "thumbnail"),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ let totalPlays = $derived(
|
||||
<div class="relative h-64 md:h-80 overflow-hidden bg-gradient-to-br from-primary to-accent">
|
||||
{#if data.model.banner}
|
||||
<img
|
||||
src={getAssetUrl(data.model.banner.id, "banner")}
|
||||
src={getAssetUrl(data.model.banner, "banner")}
|
||||
alt={$_(data.model.artist_name)}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -243,7 +243,7 @@ let totalPlays = $derived(
|
||||
<div
|
||||
class="absolute bottom-2 left-2 text-white text-sm font-medium"
|
||||
>
|
||||
{formatVideoDuration(video.movie.duration)}
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||
</div>
|
||||
<!-- <div
|
||||
class="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded-full"
|
||||
|
||||
@@ -34,11 +34,11 @@ const filteredVideos = $derived(() => {
|
||||
const matchesCategory = categoryFilter === "all";
|
||||
const matchesDuration =
|
||||
durationFilter === "all" ||
|
||||
(durationFilter === "short" && video.movie.duration < 10 * 60) ||
|
||||
(durationFilter === "short" && (video.movie_file?.duration ?? 0) < 10 * 60) ||
|
||||
(durationFilter === "medium" &&
|
||||
video.movie.duration >= 10 * 60 &&
|
||||
video.movie.duration < 20 * 60) ||
|
||||
(durationFilter === "long" && video.movie.duration >= 20 * 60);
|
||||
(video.movie_file?.duration ?? 0) >= 10 * 60 &&
|
||||
(video.movie_file?.duration ?? 0) < 20 * 60) ||
|
||||
(durationFilter === "long" && (video.movie_file?.duration ?? 0) >= 20 * 60);
|
||||
return matchesSearch && matchesCategory && matchesDuration;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -50,7 +50,7 @@ const filteredVideos = $derived(() => {
|
||||
return (b.likes_count || 0) - (a.likes_count || 0);
|
||||
if (sortBy === "most_played")
|
||||
return (b.plays_count || 0) - (a.plays_count || 0);
|
||||
if (sortBy === "duration") return b.movie.duration - a.movie.duration;
|
||||
if (sortBy === "duration") return (b.movie_file?.duration ?? 0) - (a.movie_file?.duration ?? 0);
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
@@ -220,7 +220,7 @@ const filteredVideos = $derived(() => {
|
||||
<div
|
||||
class="absolute bottom-3 left-3 bg-black/70 text-white text-sm px-2 py-1 rounded font-medium"
|
||||
>
|
||||
{formatVideoDuration(video.movie.duration)}
|
||||
{#if video.movie_file?.duration}{formatVideoDuration(video.movie_file.duration)}{/if}
|
||||
</div>
|
||||
|
||||
<!-- Premium Badge -->
|
||||
|
||||
@@ -221,7 +221,7 @@ let showPlayer = $state(false);
|
||||
<div
|
||||
class="absolute bottom-4 left-4 bg-black/70 text-white px-3 py-1 rounded font-medium"
|
||||
>
|
||||
{formatVideoDuration(data.video.movie.duration)}
|
||||
{#if data.video.movie_file?.duration}{formatVideoDuration(data.video.movie_file.duration)}{/if}
|
||||
</div>
|
||||
{#if data.video.premium}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user