mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -91,6 +91,7 @@ export const titles = sqliteTable(
|
||||
voteAverage: real("voteAverage"),
|
||||
voteCount: int("voteCount"),
|
||||
status: text("status"),
|
||||
colorPalette: text("colorPalette"),
|
||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||
},
|
||||
(table) => [
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
Reference in New Issue
Block a user