lightnet 4.10.0 → 4.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # lightnet
2
2
 
3
+ ## 4.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add `disableOpenGraphImages` to skip generating Open Graph images and use media cover images for Open Graph and Twitter previews instead. Generation remains enabled by default.
8
+
9
+ - [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add cover-colored gradients to the opening section of details pages, with softer cover shadows and clearer primary actions. Share color extraction with Open Graph images, persist calculated colors in Astro's cache directory across builds, and use CSS custom properties to keep gradient styles independent of the number of covers.
10
+
11
+ ### Patch Changes
12
+
13
+ - [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Use color tint for HighlightSection coloring.
14
+
15
+ - [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add transparent effect to headerbar.
16
+
17
+ - [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add performance improvements for open-graph image generation.
18
+
3
19
  ## 4.10.0
4
20
 
5
21
  ### Minor Changes
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export { default as ContentSection } from "../src/pages/details-page/components/ContentSection.astro"
6
6
  export { default as DescriptionSection } from "../src/pages/details-page/components/DescriptionSection.astro"
7
+ export { default as DetailsHero } from "../src/pages/details-page/components/DetailsHero.astro"
7
8
  export { default as DetailsPage } from "../src/pages/details-page/components/DetailsPage.astro"
8
9
  export { default as OpenButton } from "../src/pages/details-page/components/main-details/OpenButton.astro"
9
10
  export { default as ShareButton } from "../src/pages/details-page/components/main-details/ShareButton.astro"
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "LightNet makes it easy to run your own digital media library.",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "version": "4.10.0",
6
+ "version": "4.11.0",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/LightNetDev/lightnet",
@@ -114,6 +114,11 @@ export const configSchema = z.object({
114
114
  * not add any page of this media library to their search results.
115
115
  */
116
116
  disallowSearchIndexing: z.boolean().optional(),
117
+ /**
118
+ * Disable Open Graph image generation and use media cover images for
119
+ * Open Graph and Twitter previews instead. Generation is enabled by default.
120
+ */
121
+ disableOpenGraphImages: z.boolean().default(false),
117
122
  /**
118
123
  * Link to manifest file within public/ folder
119
124
  */
@@ -71,11 +71,13 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
71
71
  prerender: true,
72
72
  })
73
73
 
74
- injectRoute({
75
- pattern: "/[locale]/media/[mediaId]/open-graph.png",
76
- entrypoint: "lightnet/pages/detail-page/api/open-graph.ts",
77
- prerender: true,
78
- })
74
+ if (!config.disableOpenGraphImages) {
75
+ injectRoute({
76
+ pattern: "/[locale]/media/[mediaId]/open-graph.png",
77
+ entrypoint: "lightnet/pages/detail-page/api/open-graph.ts",
78
+ prerender: true,
79
+ })
80
+ }
79
81
 
80
82
  injectRoute({
81
83
  pattern: "/api/internal/search.json",
@@ -1,8 +1,11 @@
1
1
  ---
2
2
  import type { ImageMetadata } from "astro"
3
3
  import { Image } from "astro:assets"
4
+ import { cacheDir } from "astro:config/server"
4
5
  import { ArrowRightIcon } from "lucide-react"
5
6
 
7
+ import { getImageTint } from "../utils/get-image-tint"
8
+
6
9
  interface Props {
7
10
  image: ImageMetadata
8
11
  id?: string
@@ -16,14 +19,30 @@ interface Props {
16
19
 
17
20
  const { image, id, title, text, link, className, titleClass, textClass } =
18
21
  Astro.props
22
+ let tint = "#e5e7eb"
23
+ try {
24
+ tint = await getImageTint(image, new URL(cacheDir))
25
+ } catch (error) {
26
+ console.warn("[lightnet] Cannot extract highlight image color:", error)
27
+ }
28
+ const tintChannels = tint.startsWith("rgb(")
29
+ ? tint.slice(4, -1).split(",").map(Number)
30
+ : [229, 231, 235]
31
+ const darkTint = (factor: number) =>
32
+ `rgb(${tintChannels.map((channel) => Math.round(channel * factor)).join(",")})`
19
33
  const iconDirectionClass =
20
34
  Astro.locals.i18n.direction === "rtl" ? "scale-x-[-1]" : ""
21
35
  ---
22
36
 
23
37
  <section
24
- class="mt-24 w-full bg-gray-200 md:mt-28"
38
+ class="mt-24 w-full bg-[var(--image-tint)] text-gray-900 md:mt-28"
25
39
  class:list={[className]}
26
40
  id={id}
41
+ style={{
42
+ "--image-tint": tint,
43
+ "--button-tint": darkTint(0.12),
44
+ "--button-hover-tint": darkTint(0.08),
45
+ }}
27
46
  >
28
47
  <div class="flex flex-col overflow-hidden md:flex-row">
29
48
  <Image
@@ -49,7 +68,7 @@ const iconDirectionClass =
49
68
  )}
50
69
  {link && (
51
70
  <a
52
- class="inline-flex items-center justify-center gap-2 rounded-2xl bg-primary px-6 py-3 text-sm font-bold text-gray-50 shadow-sm hover:bg-primary/85 hover:text-gray-100"
71
+ class="inline-flex items-center justify-center gap-2 rounded-2xl bg-[var(--button-tint)] px-6 py-3 text-sm font-bold text-gray-50 shadow-sm transition-colors hover:bg-[var(--button-hover-tint)] hover:text-gray-100"
53
72
  href={link.href}
54
73
  >
55
74
  {link.text}
@@ -60,3 +79,13 @@ const iconDirectionClass =
60
79
  </div>
61
80
  </div>
62
81
  </section>
82
+
83
+ <style>
84
+ section {
85
+ background-image: linear-gradient(
86
+ to bottom,
87
+ rgb(255 255 255 / 25%),
88
+ transparent
89
+ );
90
+ }
91
+ </style>
@@ -3,7 +3,7 @@ import PageNavigation from "./PageNavigation.astro"
3
3
  import PageTitle from "./PageTitle.astro"
4
4
  ---
5
5
 
6
- <header class="fixed top-0 z-50 h-14 w-full bg-white shadow-md sm:h-20">
6
+ <header class="fixed top-0 z-50 h-14 w-full border-b border-black/5 before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:bg-white/85 before:backdrop-blur-lg sm:h-20">
7
7
  <div class="mx-auto flex h-full max-w-screen-xl justify-between px-4 md:px-8">
8
8
  <PageTitle />
9
9
  <PageNavigation />
@@ -21,7 +21,7 @@ const { label, disabled } = Astro.props
21
21
  data-menu-panel
22
22
  aria-hidden="true"
23
23
  inert
24
- class="pointer-events-none absolute end-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white py-3 opacity-0 shadow-lg transition-all duration-100 ease-out motion-reduce:transform-none motion-reduce:transition-none"
24
+ class="pointer-events-none absolute end-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white/85 py-3 opacity-0 shadow-lg backdrop-blur-lg transition-all duration-100 ease-out motion-reduce:transform-none motion-reduce:transition-none"
25
25
  >
26
26
  <slot />
27
27
  </ul>
@@ -2,6 +2,7 @@
2
2
  import AudioPanel from "./components/AudioPanel.astro"
3
3
  import ContentSection from "./components/ContentSection.astro"
4
4
  import DescriptionSection from "./components/DescriptionSection.astro"
5
+ import DetailsHero from "./components/DetailsHero.astro"
5
6
  import DetailsPage from "./components/DetailsPage.astro"
6
7
  import MainDetailsSection from "./components/MainDetailsSection.astro"
7
8
  import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
@@ -15,8 +16,10 @@ const { mediaId } = Astro.props
15
16
  ---
16
17
 
17
18
  <DetailsPage mediaId={mediaId} fallbackSchemaType="AudioObject">
18
- <MainDetailsSection imageSize="md" mediaId={mediaId} />
19
- <AudioPanel mediaId={mediaId} className="mt-10" />
19
+ <DetailsHero mediaId={mediaId}>
20
+ <MainDetailsSection imageSize="md" mediaId={mediaId} />
21
+ <AudioPanel mediaId={mediaId} className="mt-10" />
22
+ </DetailsHero>
20
23
  <DescriptionSection mediaId={mediaId} />
21
24
  <ContentSection mediaId={mediaId} minimumItems={1} />
22
25
  <MediaCollectionsSection mediaId={mediaId} />
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import ContentSection from "./components/ContentSection.astro"
3
3
  import DescriptionSection from "./components/DescriptionSection.astro"
4
+ import DetailsHero from "./components/DetailsHero.astro"
4
5
  import DetailsPage from "./components/DetailsPage.astro"
5
6
  import OpenButton from "./components/main-details/OpenButton.astro"
6
7
  import MainDetailsSection from "./components/MainDetailsSection.astro"
@@ -14,9 +15,11 @@ const { mediaId } = Astro.props
14
15
  ---
15
16
 
16
17
  <DetailsPage mediaId={mediaId}>
17
- <MainDetailsSection mediaId={mediaId}>
18
- <OpenButton className="mt-8" mediaId={mediaId} />
19
- </MainDetailsSection>
18
+ <DetailsHero mediaId={mediaId}>
19
+ <MainDetailsSection mediaId={mediaId}>
20
+ <OpenButton className="mt-6" mediaId={mediaId} />
21
+ </MainDetailsSection>
22
+ </DetailsHero>
20
23
  <DescriptionSection mediaId={mediaId} />
21
24
  <ContentSection mediaId={mediaId} />
22
25
  <MediaCollectionsSection mediaId={mediaId} />
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import ContentSection from "./components/ContentSection.astro"
3
3
  import DescriptionSection from "./components/DescriptionSection.astro"
4
+ import DetailsHero from "./components/DetailsHero.astro"
4
5
  import DetailsPage from "./components/DetailsPage.astro"
5
6
  import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
6
7
  import MoreDetailsSection from "./components/MoreDetailsSection.astro"
@@ -14,7 +15,9 @@ const { mediaId } = Astro.props
14
15
  ---
15
16
 
16
17
  <DetailsPage mediaId={mediaId} fallbackSchemaType="VideoObject">
17
- <VideoDetailsSection mediaId={mediaId} />
18
+ <DetailsHero mediaId={mediaId}>
19
+ <VideoDetailsSection mediaId={mediaId} />
20
+ </DetailsHero>
18
21
  <DescriptionSection mediaId={mediaId} />
19
22
  <ContentSection mediaId={mediaId} />
20
23
  <MediaCollectionsSection mediaId={mediaId} />
@@ -1,40 +1,34 @@
1
- import { readFile } from "node:fs/promises"
2
-
3
1
  import type { APIRoute, GetStaticPaths } from "astro"
4
- import { getCollection, getEntry } from "astro:content"
2
+ import { cacheDir } from "astro:config/server"
3
+ import { getCollection } from "astro:content"
5
4
  import config from "virtual:lightnet/config"
6
5
 
7
6
  import { getMediaItem } from "../../../content/get-media-items"
8
7
  import { getMediaType } from "../../../content/get-media-types"
9
- import { createOpenGraphImage } from "../utils/create-open-graph-image"
8
+ import { getCachedOpenGraphImage } from "../utils/get-cached-open-graph-image"
9
+ import { getMediaCover } from "../utils/get-media-cover"
10
10
 
11
11
  export const getStaticPaths: GetStaticPaths = async () => {
12
12
  const items = await getCollection("media")
13
- return config.locales.flatMap((locale) =>
14
- items.map(({ id: mediaId }) => ({ params: { locale, mediaId } })),
15
- )
13
+ return items.map(({ id: mediaId }) => ({
14
+ params: { locale: config.defaultLocale, mediaId },
15
+ }))
16
16
  }
17
17
 
18
18
  export const GET: APIRoute = async ({ params, locals }) => {
19
19
  const item = await getMediaItem(params.mediaId!)
20
20
  const mediaType = await getMediaType(item.data.type.id)
21
- // Read the original Astro image metadata: schema validation strips its
22
- // non-enumerable fsPath, and optimized assets are generated after prerendering.
23
- const entry = await getEntry("media", params.mediaId!)
24
- const imagePath: unknown = Reflect.get(entry!.data.image, "fsPath")
25
- if (typeof imagePath !== "string") {
26
- throw new Error(
27
- `Cannot resolve the cover image for media item "${item.id}"`,
28
- )
29
- }
30
21
  const { direction } = locals.i18n
31
- const png = await createOpenGraphImage({
32
- cover: await readFile(imagePath),
33
- coverImageStyle: mediaType.data.coverImageStyle,
34
- title: item.data.title,
35
- authors: item.data.authors,
36
- direction,
37
- })
22
+ const png = await getCachedOpenGraphImage(
23
+ {
24
+ cover: await getMediaCover(item.id),
25
+ coverImageStyle: mediaType.data.coverImageStyle,
26
+ title: item.data.title,
27
+ authors: item.data.authors,
28
+ direction,
29
+ },
30
+ new URL(cacheDir),
31
+ )
38
32
  return new Response(new Uint8Array(png), {
39
33
  headers: { "Content-Type": "image/png" },
40
34
  })
@@ -16,7 +16,12 @@ const isMp3 = audioExtension.endsWith(".mp3")
16
16
  ---
17
17
 
18
18
  {isMp3 ? (
19
- <audio class="rounded-2xl" class:list={[className]} src={src} controls />
19
+ <audio
20
+ class="overflow-hidden rounded-2xl border border-gray-200"
21
+ class:list={[className]}
22
+ src={src}
23
+ controls
24
+ />
20
25
  ) : (
21
26
  <a
22
27
  class="group flex w-full items-center gap-4 rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900/70"
@@ -47,6 +52,11 @@ const isMp3 = audioExtension.endsWith(".mp3")
47
52
  )}
48
53
 
49
54
  <style>
55
+ audio::-webkit-media-controls-enclosure,
56
+ audio::-webkit-media-controls-panel {
57
+ background-color: #f9fafb;
58
+ }
59
+
50
60
  audio::-webkit-media-controls-enclosure {
51
61
  border-radius: 0;
52
62
  }
@@ -0,0 +1,50 @@
1
+ ---
2
+ import { cacheDir } from "astro:config/server"
3
+
4
+ import { getImageTint } from "../../../utils/get-image-tint"
5
+ import { getMediaCover } from "../utils/get-media-cover"
6
+
7
+ interface Props {
8
+ mediaId: string
9
+ }
10
+
11
+ const { direction } = Astro.locals.i18n
12
+ let tint = "#fafafa"
13
+ try {
14
+ tint = await getImageTint(
15
+ await getMediaCover(Astro.props.mediaId),
16
+ new URL(cacheDir),
17
+ )
18
+ } catch (error) {
19
+ console.warn("[lightnet] Cannot extract details page cover color:", error)
20
+ }
21
+ ---
22
+
23
+ <div
24
+ class="details-hero"
25
+ style={{
26
+ "--cover-tint": tint,
27
+ "--hero-direction":
28
+ direction === "rtl" ? "to bottom left" : "to bottom right",
29
+ }}
30
+ >
31
+ <slot />
32
+ </div>
33
+
34
+ <style>
35
+ .details-hero {
36
+ /* Extend the same gradient behind the fixed header. */
37
+ margin-block-start: -3.5rem;
38
+ padding-block: calc(2.5rem + 3.5rem) 3rem;
39
+ background:
40
+ linear-gradient(to bottom, transparent 40%, #f9fafb),
41
+ linear-gradient(var(--hero-direction), var(--cover-tint), #f9fafb 85%);
42
+ }
43
+
44
+ @media (min-width: 640px) {
45
+ .details-hero {
46
+ margin-block-start: -5rem;
47
+ padding-block: calc(4rem + 5rem) 4rem;
48
+ }
49
+ }
50
+ </style>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { getImage } from "astro:assets"
2
3
  import config from "virtual:lightnet/config"
3
4
 
4
5
  import { getMediaItem } from "../../../content/get-media-items"
@@ -19,7 +20,15 @@ const { defaultLocale, currentLocale, tContentField, tConfigField } =
19
20
  const mediaItem = (await getMediaItem(id)).data
20
21
  const description = markdownToText(mediaItem.description)
21
22
  const shareUrl = new URL(detailsPagePath(currentLocale, { id }), Astro.site)
22
- const shareImage = new URL("open-graph.png", shareUrl)
23
+ const coverImage = config.disableOpenGraphImages
24
+ ? await getImage({ src: mediaItem.image, format: "png" })
25
+ : undefined
26
+ const shareImage = coverImage
27
+ ? new URL(coverImage.src, Astro.site)
28
+ : new URL(
29
+ `${detailsPagePath(defaultLocale, { id })}open-graph.png`,
30
+ Astro.site,
31
+ )
23
32
  const jsonLd = await getDetailsPageJsonLd({
24
33
  mediaId: id,
25
34
  fallbackSchemaType,
@@ -45,8 +54,14 @@ const jsonLd = await getDetailsPageJsonLd({
45
54
  <meta property="og:url" content={shareUrl.href} />
46
55
  <meta property="og:image" content={shareImage.href} />
47
56
  <meta property="og:image:type" content="image/png" />
48
- <meta property="og:image:width" content="1200" />
49
- <meta property="og:image:height" content="630" />
57
+ <meta
58
+ property="og:image:width"
59
+ content={String(coverImage ? mediaItem.image.width : 1200)}
60
+ />
61
+ <meta
62
+ property="og:image:height"
63
+ content={String(coverImage ? mediaItem.image.height : 630)}
64
+ />
50
65
  <meta property="og:image:alt" content={mediaItem.title} />
51
66
  <meta name="twitter:card" content="summary_large_image" />
52
67
  <meta name="twitter:title" content={mediaItem.title} />
@@ -13,15 +13,15 @@ export type Props = {
13
13
  const { mediaId, imageSize } = Astro.props
14
14
  ---
15
15
 
16
- <div class="mx-auto mt-10 flex max-w-screen-md flex-col items-center gap-8 px-4 sm:mt-20 sm:flex-row sm:items-start sm:gap-14 md:px-8">
16
+ <div class="mx-auto flex max-w-screen-md flex-col items-center gap-8 px-4 sm:flex-row sm:gap-14 md:px-8">
17
17
  <CoverImage size={imageSize} mediaId={mediaId} />
18
18
  <div class="flex w-full grow flex-col items-center sm:items-start">
19
19
  <Title className="text-center sm:text-start" mediaId={mediaId} />
20
20
  <Authors className="mt-2" mediaId={mediaId} />
21
- <div class="mt-4 flex gap-6">
21
+ <slot />
22
+ <div class="mt-5 flex gap-6 text-sm text-gray-600">
22
23
  <ShareButton />
23
24
  <EditButton mediaId={mediaId} />
24
25
  </div>
25
- <slot />
26
26
  </div>
27
27
  </div>
@@ -15,7 +15,7 @@ const { mediaId } = Astro.props
15
15
  const item = await getMediaItem(mediaId)
16
16
  ---
17
17
 
18
- <div class="mx-auto max-w-screen-md md:mt-8 md:px-8">
18
+ <div class="mx-auto max-w-screen-md px-4 md:px-8">
19
19
  <VideoPlayer
20
20
  url={item.data.content[0].url!}
21
21
  title={item.data.title}
@@ -25,7 +25,7 @@ const item = await getMediaItem(mediaId)
25
25
  <div class="mx-auto mt-6 flex max-w-screen-md flex-col items-start px-4 md:mt-10 md:px-8">
26
26
  <Title mediaId={mediaId} />
27
27
  <Authors mediaId={mediaId} />
28
- <div class="mt-4 flex gap-6">
28
+ <div class="mt-5 flex gap-6 text-sm text-gray-600">
29
29
  <ShareButton />
30
30
  <EditButton mediaId={mediaId} />
31
31
  </div>
@@ -27,7 +27,10 @@ const imageSizes = {
27
27
  }
28
28
  ---
29
29
 
30
- <CoverImageDecorator className="shrink-0" style={type.data.coverImageStyle}>
30
+ <CoverImageDecorator
31
+ className="shrink-0 shadow-xl shadow-gray-900/10"
32
+ style={type.data.coverImageStyle}
33
+ >
31
34
  <Image
32
35
  class:list={imageSizes[size].css}
33
36
  alt=""
@@ -1,6 +1,7 @@
1
1
  import sharp, { type OverlayOptions } from "sharp"
2
2
 
3
3
  import type { MediaTypeEntry } from "../../../content/schema/media-type"
4
+ import { getImageTint } from "../../../utils/get-image-tint"
4
5
 
5
6
  export const OPEN_GRAPH_WIDTH = 1200
6
7
  export const OPEN_GRAPH_HEIGHT = 630
@@ -25,61 +26,74 @@ async function renderText(
25
26
  {
26
27
  width,
27
28
  height,
28
- size,
29
+ maxSize,
29
30
  color,
30
31
  bold = false,
31
32
  rtl = false,
32
33
  }: {
33
34
  width: number
34
35
  height: number
35
- size: number
36
+ maxSize: number
36
37
  color: string
37
38
  bold?: boolean
38
39
  rtl?: boolean
39
40
  },
40
41
  ) {
41
- let fontSize = size
42
- // Measure the rendered text and reduce the font until every line fits.
43
- // Pango handles Unicode shaping, bidi text, and wrapping long words.
44
- while (true) {
45
- const result = await sharp({
42
+ // Estimate the occupied area using average glyph width (0.55 em) and
43
+ // line height (1.2 em). Count Unicode code points rather than UTF-16 units.
44
+ const size = Math.max(
45
+ 1,
46
+ Math.min(
47
+ maxSize,
48
+ Math.floor(
49
+ Math.sqrt((width * height) / ([...value].length * 0.55 * 1.2)),
50
+ ),
51
+ ),
52
+ )
53
+ const render = (fit = false) =>
54
+ sharp({
46
55
  text: {
47
56
  text: `<span foreground="${color}">${escapeMarkup(value)}</span>`,
48
- font: `sans ${bold ? "bold " : ""}${fontSize}`,
57
+ font: `sans ${bold ? "bold " : ""}${size}`,
49
58
  width,
59
+ ...(fit ? { height } : {}),
50
60
  wrap: "word-char",
51
61
  align: rtl ? "right" : "left",
52
62
  rgba: true,
53
63
  },
54
64
  })
55
- .png()
65
+ .raw()
56
66
  .toBuffer({ resolveWithObject: true })
57
- if (result.info.height <= height) return result
58
- fontSize *= 0.9
59
- }
67
+ const result = await render()
68
+ // Wide glyphs, shaping, and explicit line breaks can exceed the estimate.
69
+ return result.info.height <= height ? result : render(true)
60
70
  }
61
71
 
62
- export async function createOpenGraphImage({
63
- cover,
64
- title,
65
- authors = [],
66
- direction = "ltr",
67
- coverImageStyle = "default",
68
- }: {
69
- cover: Buffer
70
- title: string
71
- authors?: string[] | null
72
- direction?: "ltr" | "rtl"
73
- coverImageStyle?: MediaTypeEntry["data"]["coverImageStyle"]
74
- }) {
72
+ export async function createOpenGraphImage(
73
+ {
74
+ cover,
75
+ title,
76
+ authors = [],
77
+ direction = "ltr",
78
+ coverImageStyle = "default",
79
+ }: {
80
+ cover: Buffer
81
+ title: string
82
+ authors?: string[] | null
83
+ direction?: "ltr" | "rtl"
84
+ coverImageStyle?: MediaTypeEntry["data"]["coverImageStyle"]
85
+ },
86
+ cacheDir?: URL,
87
+ ) {
75
88
  const rtl = direction === "rtl"
76
89
  const textStart = rtl ? 64 : 560
77
90
  const coverImage = await sharp(cover)
78
91
  .autoOrient()
79
92
  .resize(432, 502, { fit: "inside" })
80
- .png()
93
+ .ensureAlpha()
94
+ .raw()
81
95
  .toBuffer({ resolveWithObject: true })
82
- const { dominant } = await sharp(coverImage.data).stats()
96
+ const tint = await getImageTint(cover, cacheDir)
83
97
  const { width: coverWidth, height: coverHeight } = coverImage.info
84
98
  const cornerRadius = coverImageStyle === "book" ? 2 : 6
85
99
  const decorations: OverlayOptions[] = []
@@ -102,20 +116,17 @@ export async function createOpenGraphImage({
102
116
  </svg>`),
103
117
  blend: "dest-in",
104
118
  })
105
- const decoratedCover = await sharp(coverImage.data)
106
- .ensureAlpha()
119
+ const decoratedCover = await sharp(coverImage.data, { raw: coverImage.info })
107
120
  .composite(decorations)
108
- .png()
109
- .toBuffer()
110
- const tint = `rgb(${[dominant.r, dominant.g, dominant.b]
111
- .map((channel) => Math.round(250 * 0.68 + channel * 0.32))
112
- .join(",")})`
121
+ .raw()
122
+ .toBuffer({ resolveWithObject: true })
113
123
  const coverLeft =
114
124
  (rtl ? 704 : 64) + Math.floor((432 - coverImage.info.width) / 2)
115
125
  const coverTop = 64 + Math.floor((502 - coverImage.info.height) / 2)
116
126
  const layers: OverlayOptions[] = [
117
127
  {
118
- input: decoratedCover,
128
+ input: decoratedCover.data,
129
+ raw: decoratedCover.info,
119
130
  left: coverLeft,
120
131
  top: coverTop,
121
132
  },
@@ -125,7 +136,7 @@ export async function createOpenGraphImage({
125
136
  value: string,
126
137
  top: number,
127
138
  height: number,
128
- size: number,
139
+ maxSize: number,
129
140
  color: string,
130
141
  bold = false,
131
142
  ) => {
@@ -133,13 +144,14 @@ export async function createOpenGraphImage({
133
144
  const text = await renderText(value, {
134
145
  width: 576,
135
146
  height,
136
- size,
147
+ maxSize,
137
148
  color,
138
149
  bold,
139
150
  rtl,
140
151
  })
141
152
  textLayers.push({
142
153
  input: text.data,
154
+ raw: text.info,
143
155
  left: textStart + (rtl ? 576 - text.info.width : 0),
144
156
  top,
145
157
  })
@@ -0,0 +1,52 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
3
+
4
+ import sharp from "sharp"
5
+
6
+ import { createOpenGraphImage } from "./create-open-graph-image"
7
+
8
+ // Bump when changing the renderer, layout, or fonts to invalidate old images.
9
+ const RENDERER_VERSION = 345
10
+
11
+ export async function getCachedOpenGraphImage(
12
+ input: Parameters<typeof createOpenGraphImage>[0],
13
+ cacheDir: URL,
14
+ ) {
15
+ const key = createHash("sha256")
16
+ .update(
17
+ JSON.stringify({
18
+ version: RENDERER_VERSION,
19
+ dependencies: sharp.versions,
20
+ title: input.title,
21
+ authors: input.authors ?? [],
22
+ direction: input.direction ?? "ltr",
23
+ coverImageStyle: input.coverImageStyle ?? "default",
24
+ }),
25
+ )
26
+ .update(input.cover)
27
+ .digest("hex")
28
+ const directory = new URL("lightnet/open-graph/", cacheDir)
29
+ const imagePath = new URL(`${key}.png`, directory)
30
+
31
+ try {
32
+ return await readFile(imagePath)
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
35
+ console.warn("[lightnet] Cannot read Open Graph image cache:", error)
36
+ return createOpenGraphImage(input, cacheDir)
37
+ }
38
+ }
39
+
40
+ const png = await createOpenGraphImage(input, cacheDir)
41
+ const temporaryPath = new URL(`${key}.${randomUUID()}.tmp`, directory)
42
+ try {
43
+ await mkdir(directory, { recursive: true })
44
+ await writeFile(temporaryPath, png, { flag: "wx" })
45
+ await rename(temporaryPath, imagePath)
46
+ } catch (error) {
47
+ console.warn("[lightnet] Cannot write Open Graph image cache:", error)
48
+ } finally {
49
+ await rm(temporaryPath, { force: true }).catch(() => undefined)
50
+ }
51
+ return png
52
+ }
@@ -0,0 +1,16 @@
1
+ import { readFile } from "node:fs/promises"
2
+
3
+ import { getEntry } from "astro:content"
4
+
5
+ export async function getMediaCover(mediaId: string) {
6
+ // Schema validation strips fsPath; optimized assets are not available yet
7
+ // during prerendering. Read the original Astro image metadata instead.
8
+ const entry = await getEntry("media", mediaId)
9
+ const imagePath: unknown = entry && Reflect.get(entry.data.image, "fsPath")
10
+ if (typeof imagePath !== "string") {
11
+ throw new Error(
12
+ `Cannot resolve the cover image for media item "${mediaId}"`,
13
+ )
14
+ }
15
+ return readFile(imagePath)
16
+ }
@@ -0,0 +1,144 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
3
+
4
+ import type { ImageMetadata } from "astro"
5
+ import sharp from "sharp"
6
+
7
+ // Bound the cache for long-running dev/SSR servers. Locales share the same tint.
8
+ const tints = new Map<string, Promise<string>>()
9
+ // Bump when changing the extraction or tint formula.
10
+ const TINT_VERSION = 2
11
+
12
+ function getChromaticColor(pixels: Buffer): number[] | undefined {
13
+ // Group similar RGB colors into 16 levels per channel.
14
+ const weights = new Float64Array(4096)
15
+ const reds = new Float64Array(4096)
16
+ const greens = new Float64Array(4096)
17
+ const blues = new Float64Array(4096)
18
+ let visibleWeight = 0
19
+ let colorfulWeight = 0
20
+ let largestBucket = 0
21
+
22
+ for (let offset = 0; offset < pixels.length; offset += 4) {
23
+ const r = pixels[offset]
24
+ const g = pixels[offset + 1]
25
+ const b = pixels[offset + 2]
26
+ const weight = pixels[offset + 3] / 255
27
+ visibleWeight += weight
28
+
29
+ const max = Math.max(r, g, b)
30
+ const min = Math.min(r, g, b)
31
+ if (
32
+ weight === 0 ||
33
+ max < 64 ||
34
+ min > 230 ||
35
+ max - min < 40 ||
36
+ (max - min) / max < 0.15
37
+ ) {
38
+ continue
39
+ }
40
+
41
+ const bucket = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)
42
+ weights[bucket] += weight
43
+ reds[bucket] += r * weight
44
+ greens[bucket] += g * weight
45
+ blues[bucket] += b * weight
46
+ colorfulWeight += weight
47
+ if (weights[bucket] > weights[largestBucket]) largestBucket = bucket
48
+ }
49
+
50
+ // Tiny colorful accents should not determine an otherwise neutral image.
51
+ if (colorfulWeight === 0 || colorfulWeight < visibleWeight * 0.05) {
52
+ return undefined
53
+ }
54
+ return [reds[largestBucket], greens[largestBucket], blues[largestBucket]].map(
55
+ (sum) => sum / weights[largestBucket],
56
+ )
57
+ }
58
+
59
+ export async function getImageTint(
60
+ image: Buffer | ImageMetadata,
61
+ cacheDir?: URL,
62
+ ): Promise<string> {
63
+ if (!Buffer.isBuffer(image)) {
64
+ // Read the original asset; optimized image URLs may not exist during prerendering.
65
+ const imagePath: unknown = Reflect.get(image, "fsPath")
66
+ if (typeof imagePath !== "string") {
67
+ throw new Error(`Cannot resolve image "${image.src}"`)
68
+ }
69
+ image = await readFile(imagePath)
70
+ }
71
+
72
+ const key = createHash("sha256")
73
+ .update(
74
+ JSON.stringify({ version: TINT_VERSION, dependencies: sharp.versions }),
75
+ )
76
+ .update(image)
77
+ .digest("hex")
78
+ const directory = cacheDir && new URL("lightnet/image-tints/", cacheDir)
79
+ const tintPath = directory && new URL(`${key}.txt`, directory)
80
+ const memoryKey = `${cacheDir?.href ?? ""}:${key}`
81
+ const cached = tints.get(memoryKey)
82
+ if (cached) return cached
83
+
84
+ const tint = (async () => {
85
+ if (tintPath) {
86
+ try {
87
+ const value = await readFile(tintPath, "utf8")
88
+ const channels = /^rgb\((\d+),(\d+),(\d+)\)$/.exec(value)
89
+ if (
90
+ channels &&
91
+ channels.slice(1).every((channel) => Number(channel) <= 255)
92
+ ) {
93
+ return value
94
+ }
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
97
+ console.warn("[lightnet] Cannot read image tint cache:", error)
98
+ }
99
+ }
100
+ }
101
+ const decoded = await sharp(image)
102
+ .autoOrient()
103
+ .resize(432, 502, { fit: "inside" })
104
+ .toColourspace("srgb")
105
+ .ensureAlpha()
106
+ .raw()
107
+ .toBuffer({ resolveWithObject: true })
108
+ let color = getChromaticColor(decoded.data)
109
+ if (!color) {
110
+ // Materialize flattening before stats(), which reads input pixels directly.
111
+ const opaque = await sharp(decoded.data, { raw: decoded.info })
112
+ .flatten({ background: "#fafafa" })
113
+ .raw()
114
+ .toBuffer({ resolveWithObject: true })
115
+ const { dominant } = await sharp(opaque.data, {
116
+ raw: opaque.info,
117
+ }).stats()
118
+ color = [dominant.r, dominant.g, dominant.b]
119
+ }
120
+ // Keep every channel at least 170 for readable dark text on any image tint.
121
+ const value = `rgb(${color
122
+ .map((channel) => Math.round(250 * 0.68 + channel * 0.32))
123
+ .join(",")})`
124
+ if (directory && tintPath) {
125
+ const temporaryPath = new URL(`${key}.${randomUUID()}.tmp`, directory)
126
+ try {
127
+ await mkdir(directory, { recursive: true })
128
+ await writeFile(temporaryPath, value, { flag: "wx" })
129
+ await rename(temporaryPath, tintPath)
130
+ } catch (error) {
131
+ console.warn("[lightnet] Cannot write image tint cache:", error)
132
+ } finally {
133
+ await rm(temporaryPath, { force: true }).catch(() => undefined)
134
+ }
135
+ }
136
+ return value
137
+ })()
138
+ if (tints.size >= 128) tints.delete(tints.keys().next().value!)
139
+ tints.set(memoryKey, tint)
140
+ void tint.catch(() => {
141
+ if (tints.get(memoryKey) === tint) tints.delete(memoryKey)
142
+ })
143
+ return tint
144
+ }