Add dynamic color theming from poster art using node-vibrant

Extract dominant colors from movie/TV poster images server-side and use
them to create per-title atmospheric effects on detail pages — tinted
backdrop gradients, ambient glow orbs, and colored poster shadows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 12:29:13 -05:00
co-authored by Claude Opus 4.6
parent 67356e04c6
commit cb0f366d44
9 changed files with 2481 additions and 6 deletions
+55
View File
@@ -0,0 +1,55 @@
import { eq } from "drizzle-orm";
import { Vibrant } from "node-vibrant/node";
import { db } from "@/lib/db/client";
import { titles } from "@/lib/db/schema";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export interface ColorPalette {
vibrant: string | null;
darkVibrant: string | null;
lightVibrant: string | null;
muted: string | null;
darkMuted: string | null;
lightMuted: string | null;
}
export async function extractAndStoreColors(
titleId: string,
posterPath: string | null,
): Promise<ColorPalette | null> {
const url = tmdbImageUrl(posterPath, "w300");
if (!url) return null;
try {
const palette = await Vibrant.from(url).getPalette();
const colors: ColorPalette = {
vibrant: palette.Vibrant?.hex ?? null,
darkVibrant: palette.DarkVibrant?.hex ?? null,
lightVibrant: palette.LightVibrant?.hex ?? null,
muted: palette.Muted?.hex ?? null,
darkMuted: palette.DarkMuted?.hex ?? null,
lightMuted: palette.LightMuted?.hex ?? null,
};
await db
.update(titles)
.set({ colorPalette: JSON.stringify(colors) })
.where(eq(titles.id, titleId))
.run();
return colors;
} catch (err) {
console.error(`Failed to extract colors for title ${titleId}:`, err);
return null;
}
}
export function parseColorPalette(raw: string | null): ColorPalette | null {
if (!raw) return null;
try {
return JSON.parse(raw) as ColorPalette;
} catch {
return null;
}
}
+14 -3
View File
@@ -14,6 +14,7 @@ import {
getTvSeasonDetails,
} from "@/lib/tmdb/client";
import { refreshAvailability } from "./availability";
import { extractAndStoreColors } from "./colors";
export async function importTitle(tmdbId: number, type: "movie" | "tv") {
const existing = await db
@@ -80,9 +81,10 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
})
.returning()
.get();
// Fire-and-forget: fetch availability & recommendations
// Fire-and-forget: fetch availability, recommendations & colors
refreshAvailability(row.id).catch(() => {});
refreshRecommendations(row.id).catch(() => {});
extractAndStoreColors(row.id, movie.poster_path).catch(() => {});
return row;
}
@@ -108,9 +110,10 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
.get();
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
// Fire-and-forget: fetch availability & recommendations
// Fire-and-forget: fetch availability, recommendations & colors
refreshAvailability(row.id).catch(() => {});
refreshRecommendations(row.id).catch(() => {});
extractAndStoreColors(row.id, show.poster_path).catch(() => {});
return row;
}
@@ -165,7 +168,15 @@ export async function refreshTitle(titleId: string) {
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
}
return db.select().from(titles).where(eq(titles.id, titleId)).get();
const updated = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (updated) {
extractAndStoreColors(updated.id, updated.posterPath).catch(() => {});
}
return updated;
}
export async function refreshTvChildren(