feat(gallery): refine image-sampled border animation

This commit is contained in:
Codex
2026-05-14 03:01:48 +00:00
parent 57e9fd8459
commit cea408fb5d
4 changed files with 145 additions and 19 deletions

View File

@@ -77,7 +77,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `limit`, `offset`, `sort`, `q/search`; SQL where/order. |
| Gallery search box looks inconsistent with the rest of the UI | `src/app/gallery/page.tsx` | The search field is a custom glass panel with an inner focused input surface; avoid reverting it to a plain transparent input row. |
| Gallery hover makes images muddy, covers the image with prompt text, shows a rotating glow block/perimeter frame, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, broad square glow under the card, or rotating border frame. Keep hover feedback on the card container with scale plus a sampled-color static highlight, and keep like/download buttons legible through sampled image brightness inversion. |
| Gallery hover makes images muddy, covers the image with prompt text, shows only a single-color/static glow, has transparent gaps, does not match image colors, misses the card corners, moves too fast, looks too hard-edged, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, transparent border gaps, generated unrelated colors, broad square glow under the card, or a separate outer halo layer. Keep hover feedback on the card container with scale plus a real `gallery-card-border-frame` wrapper using 3-5 sampled image colors in a single blurred 3px continuous clockwise border around the full work-card container, including all four corners and the prompt/footer area, and keep like/download buttons legible through sampled image brightness inversion. |
## Canvas

View File

@@ -102,7 +102,7 @@ Use this document to jump directly to code before broad searching.
| Feature | Files | Notes |
| --- | --- | --- |
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download. The search box is custom styled in-page to match the glass UI; gallery cards use hover scale plus a sampled-color static highlight instead of an image-covering dark overlay, broad glow block, or rotating perimeter frame, and hover like/download buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. |
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies media into gallery folders and inserts public work. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works and published state. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |

View File

@@ -153,6 +153,8 @@ type GalleryCardPalette = {
accent1: string;
accent2: string;
accent3: string;
accent4: string;
accent5: string;
actionBg: string;
actionFg: string;
actionBorder: string;
@@ -160,6 +162,7 @@ type GalleryCardPalette = {
};
type RgbColor = { r: number; g: number; b: number };
type ScoredColor = { color: RgbColor; score: number };
function clampColor(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
@@ -190,6 +193,18 @@ function getSaturation(color: RgbColor): number {
return max === 0 ? 0 : (max - min) / max;
}
function getRgbDistance(a: RgbColor, b: RgbColor): number {
const dr = a.r - b.r;
const dg = a.g - b.g;
const db = a.b - b.b;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
function getHueDistance(a: number, b: number): number {
const diff = Math.abs(a - b);
return Math.min(diff, 1 - diff);
}
function rgbToHsl({ r, g, b }: RgbColor) {
const nr = r / 255, ng = g / 255, nb = b / 255;
const max = Math.max(nr, ng, nb), min = Math.min(nr, ng, nb);
@@ -242,6 +257,43 @@ function makeVivid(color: RgbColor): RgbColor {
return hslToRgb(hsl);
}
function selectImageAccentColors(candidates: ScoredColor[], average: RgbColor, strongest: RgbColor): RgbColor[] {
const sorted = [...candidates].sort((a, b) => b.score - a.score);
const selected: RgbColor[] = [];
const passes = [
{ hueDistance: 0.13, rgbDistance: 86 },
{ hueDistance: 0.08, rgbDistance: 58 },
{ hueDistance: 0.035, rgbDistance: 34 },
];
for (const pass of passes) {
for (const candidate of sorted) {
if (selected.length >= 5) break;
const vivid = makeVivid(candidate.color);
const vividHue = rgbToHsl(vivid).h;
const isDistinct = selected.every((color) => {
const colorHue = rgbToHsl(color).h;
return getHueDistance(vividHue, colorHue) >= pass.hueDistance || getRgbDistance(vivid, color) >= pass.rgbDistance;
});
if (isDistinct) {
selected.push(vivid);
}
}
if (selected.length >= 5) break;
}
for (const fallback of [strongest, average]) {
const vivid = makeVivid(fallback);
if (selected.length === 0 || selected.every(color => getRgbDistance(vivid, color) >= 24)) {
selected.push(vivid);
}
if (selected.length >= 3) break;
}
const byHue = selected.slice(0, 5).sort((a, b) => rgbToHsl(a).h - rgbToHsl(b).h);
return byHue.length > 0 ? byHue : [makeVivid(strongest)];
}
function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
try {
const canvas = document.createElement('canvas');
@@ -257,6 +309,7 @@ function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
const average: RgbColor = { r: 0, g: 0, b: 0 };
let strongest: RgbColor = { r: 245, g: 166, b: 35 };
let strongestScore = -1;
const candidates: ScoredColor[] = [];
for (let index = 0; index < data.length; index += 4) {
const alpha = data[index + 3];
@@ -272,6 +325,7 @@ function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
const saturation = getSaturation(color);
const score = saturation * 0.7 + (1 - Math.abs(luminance - 0.55)) * 0.3;
candidates.push({ color, score });
if (score > strongestScore) {
strongestScore = score;
strongest = color;
@@ -283,15 +337,16 @@ function getImagePalette(img: HTMLImageElement): GalleryCardPalette | null {
average.r /= total;
average.g /= total;
average.b /= total;
const vividAccent = makeVivid(strongest);
const accents = selectImageAccentColors(candidates, average, strongest);
const imageIsDark = getLuminance(average) < 0.48;
const softAccent = mixRgb(vividAccent, imageIsDark ? { r: 255, g: 255, b: 255 } : { r: 0, g: 0, b: 0 }, imageIsDark ? 0.26 : 0.18);
const warmAccent = mixRgb(vividAccent, { r: 245, g: 166, b: 35 }, 0.38);
const colorAt = (index: number) => accents[index % accents.length] || makeVivid(strongest);
return {
accent1: rgbToCss(vividAccent),
accent2: rgbToCss(softAccent),
accent3: rgbToCss(warmAccent),
accent1: rgbToCss(colorAt(0)),
accent2: rgbToCss(colorAt(1)),
accent3: rgbToCss(colorAt(2)),
accent4: rgbToCss(colorAt(3)),
accent5: rgbToCss(colorAt(4)),
actionBg: imageIsDark ? 'rgb(255 255 255 / 0.92)' : 'rgb(13 18 28 / 0.86)',
actionFg: imageIsDark ? 'rgb(17 24 39)' : 'rgb(255 255 255)',
actionBorder: imageIsDark ? 'rgb(255 255 255 / 0.72)' : 'rgb(255 255 255 / 0.22)',
@@ -309,6 +364,8 @@ function getGalleryCardStyle(palette?: GalleryCardPalette): CSSProperties {
'--gallery-accent-1': palette?.accent1 || 'rgb(245 166 35)',
'--gallery-accent-2': palette?.accent2 || 'rgb(56 189 248)',
'--gallery-accent-3': palette?.accent3 || 'rgb(244 114 182)',
'--gallery-accent-4': palette?.accent4 || palette?.accent2 || 'rgb(34 197 94)',
'--gallery-accent-5': palette?.accent5 || palette?.accent1 || 'rgb(168 85 247)',
'--gallery-action-bg': palette?.actionBg || 'rgb(255 255 255 / 0.92)',
'--gallery-action-fg': palette?.actionFg || 'rgb(17 24 39)',
'--gallery-action-border': palette?.actionBorder || 'rgb(255 255 255 / 0.58)',
@@ -712,10 +769,11 @@ export default function GalleryPage() {
className="gallery-work-shell group"
style={getGalleryCardStyle(cardPalettes[work.id])}
>
<Card
className={`${galleryGlassCard} gallery-work-card w-full overflow-hidden cursor-pointer !rounded-2xl !py-0`}
onClick={() => setSelectedWork(work)}
>
<div className="gallery-card-border-frame">
<Card
className={`${galleryGlassCard} gallery-work-card w-full overflow-hidden cursor-pointer !rounded-[14px] !py-0`}
onClick={() => setSelectedWork(work)}
>
<div className="relative overflow-hidden bg-black/25">
{mediaPreviewUrl ? (
<img
@@ -745,11 +803,11 @@ export default function GalleryPage() {
</button>
)}
{(work.type === 'video' || work.type === 'text2video' || work.type === 'img2video') && (
<Badge className={`absolute left-2 ${isAdmin && apiWorkIds.has(work.id) ? 'top-11' : 'top-2'}`} variant="secondary">
<Badge className={`absolute left-2 z-20 ${isAdmin && apiWorkIds.has(work.id) ? 'top-11' : 'top-2'}`} variant="secondary">
<Film className="h-3 w-3 mr-1" />
</Badge>
)}
<Badge className="absolute top-2 right-2" variant="secondary">
<Badge className="absolute top-2 right-2 z-20" variant="secondary">
{getCategoryLabel(work)}
</Badge>
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-20 flex translate-y-2 justify-center gap-2 opacity-0 transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100">
@@ -812,7 +870,8 @@ export default function GalleryPage() {
{work.prompt}
</p>
</CardContent>
</Card>
</Card>
</div>
</div>
);
})}

View File

@@ -2,6 +2,12 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@property --gallery-border-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -227,7 +233,7 @@
.gallery-work-shell:hover {
z-index: 10;
transform: translateY(-6px) scale(1.02);
filter: saturate(1.03);
filter: saturate(1.12);
}
.gallery-work-card {
@@ -239,12 +245,63 @@
background-color 300ms ease;
}
.gallery-card-border-frame {
--gallery-border-angle: 0deg;
position: relative;
border-radius: 1rem;
background: transparent;
}
.gallery-card-border-frame::before {
content: "";
position: absolute;
z-index: 0;
pointer-events: none;
background:
conic-gradient(
from var(--gallery-border-angle),
var(--gallery-accent-1) 0deg,
var(--gallery-accent-2) 72deg,
var(--gallery-accent-3) 144deg,
var(--gallery-accent-4) 216deg,
var(--gallery-accent-5) 288deg,
var(--gallery-accent-1) 360deg
);
opacity: 0;
transition: opacity 220ms ease;
animation: gallery-clockwise-flow 5.2s linear infinite;
animation-play-state: paused;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
.gallery-card-border-frame::before {
inset: -3px;
border-radius: calc(1rem + 3px);
padding: 3px;
filter: blur(1.2px) saturate(1.75) brightness(1.08);
}
.gallery-card-border-frame .gallery-work-card {
position: relative;
z-index: 1;
}
.gallery-work-shell:hover .gallery-card-border-frame::before {
opacity: 0.96;
animation-play-state: running;
}
.gallery-work-shell:hover .gallery-work-card {
border-color: color-mix(in srgb, var(--gallery-accent-1) 48%, white 24%);
border-color: color-mix(in srgb, var(--gallery-accent-2) 28%, white 10%);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--gallery-accent-1) 42%, transparent),
0 16px 34px rgb(0 0 0 / 0.18),
0 0 24px color-mix(in srgb, var(--gallery-accent-2) 22%, transparent),
inset 0 1px 0 rgb(255 255 255 / 0.12);
}
@@ -273,8 +330,18 @@
color: currentColor;
}
@keyframes gallery-clockwise-flow {
from {
--gallery-border-angle: 0deg;
}
to {
--gallery-border-angle: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.gallery-work-shell,
.gallery-card-border-frame::before,
.gallery-work-action-button {
animation: none !important;
transition-duration: 1ms !important;