diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 123858ca4..a3bad1ca2 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -8,6 +8,7 @@ export const dynamic = "force-dynamic"; export const revalidate = 0; const DEFAULT_CONTENT_TYPE = "image/x-icon"; +const DARK_FAVICON_CONTENT_TYPE = "image/png"; const FETCH_TIMEOUT_MS = 10_000; function noStoreHeaders(contentType: string) { @@ -56,27 +57,103 @@ function resolveFaviconUrl(rawUrl: string, requestUrl: string) { } } -async function readLocalFallbackFavicon() { - const file = await readFile(join(process.cwd(), "public", "default-favicon.ico")); +async function readLocalFallbackFavicon(preferDark = false) { + const file = await readFile( + join(process.cwd(), "public", "default-favicon.ico") + ); + if (preferDark) { + const transformed = await transformFaviconForDark(file).catch(() => null); + const fallback = + transformed || (await renderDarkFallbackFavicon().catch(() => null)); + if (fallback) { + return new NextResponse(fallback, { + status: 200, + headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE), + }); + } + } return new NextResponse(file, { status: 200, headers: noStoreHeaders(DEFAULT_CONTENT_TYPE), }); } +async function renderDarkFallbackFavicon() { + const sharp = (await import("sharp")).default; + const svg = `\ +`; + return await sharp(Buffer.from(svg)).png().toBuffer(); +} + +async function transformFaviconForDark(buffer: Buffer | ArrayBuffer) { + const sharp = (await import("sharp")).default; + const inputBuffer = Buffer.isBuffer(buffer) + ? buffer + : Buffer.from(new Uint8Array(buffer)); + const createBaseImage = () => + sharp(inputBuffer, { + animated: false, + failOn: "none", + }).resize({ + width: 64, + height: 64, + fit: "contain", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }); + + const { data, info } = await createBaseImage() + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let visiblePixelCount = 0; + let luminanceTotal = 0; + + for (let index = 0; index < data.length; index += info.channels) { + const alpha = data[index + 3] ?? 255; + if (alpha < 24) continue; + + const red = data[index] ?? 0; + const green = data[index + 1] ?? 0; + const blue = data[index + 2] ?? 0; + luminanceTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue; + visiblePixelCount += 1; + } + + const averageLuminance = + visiblePixelCount > 0 ? luminanceTotal / visiblePixelCount : 0; + const needsInvertForDark = averageLuminance < 150; + const output = createBaseImage(); + + if (needsInvertForDark) { + output.negate({ alpha: false }); + } + + return await output + .png() + .toBuffer(); +} + export async function GET(request: Request) { try { + const requestUrl = new URL(request.url); + const preferDark = requestUrl.searchParams.get("theme") === "dark"; const settings = await getSiteSettings(); - const rawFaviconUrl = String(settings?.favicon || "").trim(); + const rawFaviconUrl = String( + (preferDark && settings?.siteLogoDark) || settings?.favicon || "" + ).trim(); if (!rawFaviconUrl) { - return await readLocalFallbackFavicon(); + return await readLocalFallbackFavicon(preferDark); } const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url); if (!faviconUrl) { console.error("[favicon] Invalid favicon URL:", rawFaviconUrl); - return await readLocalFallbackFavicon(); + return await readLocalFallbackFavicon(preferDark); } const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`; @@ -104,19 +181,35 @@ export async function GET(request: Request) { status: upstream.status, statusText: upstream.statusText, }); - return await readLocalFallbackFavicon(); + return await readLocalFallbackFavicon(preferDark); } const buffer = await upstream.arrayBuffer(); const contentType = upstream.headers.get("content-type") || inferContentType(faviconUrl); + if (preferDark) { + const transformed = await transformFaviconForDark(buffer).catch((error) => { + console.error("[favicon] Failed to transform dark favicon:", error); + return null; + }); + const darkFavicon = + transformed || (await renderDarkFallbackFavicon().catch(() => null)); + if (darkFavicon) { + return new NextResponse(darkFavicon, { + status: 200, + headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE), + }); + } + } + return new NextResponse(buffer, { status: 200, headers: noStoreHeaders(contentType), }); } catch (error) { console.error("[favicon] Failed to proxy favicon:", error); - return await readLocalFallbackFavicon(); + const preferDark = new URL(request.url).searchParams.get("theme") === "dark"; + return await readLocalFallbackFavicon(preferDark); } } diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 50fd0b668..c2d0ed6f7 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -9034,6 +9034,22 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const resolvedAgentProjectTitle = hasActiveAgentProject ? String(agentProjectTitle || "").trim() || "未命名生图项目" : "未选择项目"; + const isDarkCanvasTheme = resolvedTheme === "dark"; + const lightCanvasBrandLogo = String(siteSettingsData?.siteLogo || "").trim(); + const darkCanvasBrandLogo = String(siteSettingsData?.siteLogoDark || "").trim(); + const hasSeparateDarkCanvasBrandLogo = + Boolean(darkCanvasBrandLogo) && darkCanvasBrandLogo !== lightCanvasBrandLogo; + const canvasBrandLogo = String( + isDarkCanvasTheme + ? hasSeparateDarkCanvasBrandLogo + ? darkCanvasBrandLogo + : lightCanvasBrandLogo || darkCanvasBrandLogo + : lightCanvasBrandLogo || darkCanvasBrandLogo + ).trim(); + const shouldInvertCanvasBrandLogo = + isDarkCanvasTheme && + Boolean(canvasBrandLogo) && + !hasSeparateDarkCanvasBrandLogo; const brandEntry = (