diff --git a/package.json b/package.json index db07db5a..ffd62d32 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "hast-util-to-html": "9.0.0", "i18next": "23.7.6", "i18next-resources-to-backend": "1.2.0", + "image-size": "^1.0.2", "immer": "10.0.3", "ioredis": "5.3.2", "isomorphic-dompurify": "1.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd94c0eb..4a483d2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ dependencies: i18next-resources-to-backend: specifier: 1.2.0 version: 1.2.0 + image-size: + specifier: ^1.0.2 + version: 1.0.2 immer: specifier: 10.0.3 version: 10.0.3 @@ -7564,6 +7567,14 @@ packages: engines: {node: '>= 4'} dev: true + /image-size@1.0.2: + resolution: {integrity: sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + queue: 6.0.2 + dev: false + /immer@10.0.3: resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==} dev: false @@ -10385,6 +10396,12 @@ packages: resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} dev: false + /queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + dependencies: + inherits: 2.0.4 + dev: false + /quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} dev: false diff --git a/prisma/migrations/20231027231138_/migration.sql b/prisma/migrations/20231027231138_/migration.sql new file mode 100644 index 00000000..4f1be50a --- /dev/null +++ b/prisma/migrations/20231027231138_/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Metadata" ADD COLUMN "image_dimensions" JSONB; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c47d33ff..d60ea06a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,15 @@ model Metadata { ai_summary_ja String? ai_score Int? ai_score_reason String? + // Expected JSON structure for the image_dimensions field: + // { + // "": { + // "width": 123, + // "height": 123 + // }, + // ... + // } + image_dimensions Json? @@id([uri]) @@index([uri]) } diff --git a/src/app/api/feed/route.ts b/src/app/api/feed/route.ts index b6b7c182..4aca07e8 100644 --- a/src/app/api/feed/route.ts +++ b/src/app/api/feed/route.ts @@ -1,5 +1,6 @@ import { getQuery, NextServerResponse } from "~/lib/server-helper" import { getFeed } from "~/models/home.model" +import { decoratePageForImageDimensions } from "~/queries/page.server" export async function GET(req: Request) { const query = getQuery(req) @@ -16,6 +17,11 @@ export async function GET(req: Request) { useHTML: false, topic: query.topic, }) + + for (const item of result.list) { + await decoratePageForImageDimensions(item) + } + const res = new NextServerResponse() return res.status(200).json(result) } diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts index 74326e02..b37404fc 100644 --- a/src/app/api/pages/route.ts +++ b/src/app/api/pages/route.ts @@ -1,6 +1,7 @@ import { getQuery, NextServerResponse } from "~/lib/server-helper" import { PagesSortTypes, PageVisibilityEnum } from "~/lib/types" import { getPagesBySite } from "~/models/page.model" +import { decoratePageForImageDimensions } from "~/queries/page.server" export async function GET(req: Request) { const query = getQuery(req) @@ -20,6 +21,11 @@ export async function GET(req: Request) { : [query.tags as string], }), }) + + for (const item of result.list) { + await decoratePageForImageDimensions(item) + } + const res = new NextServerResponse() return res.status(200).json(result) } diff --git a/src/lib/types.ts b/src/lib/types.ts index 1b190844..6cf62609 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -89,6 +89,7 @@ export type ExpandedNote = NoteEntity & { summary?: string cover?: string images?: string[] + imageDimensions?: Record frontMatter?: Record slug?: string audio?: string diff --git a/src/queries/page.server.ts b/src/queries/page.server.ts index b0958619..8985330b 100644 --- a/src/queries/page.server.ts +++ b/src/queries/page.server.ts @@ -1,4 +1,7 @@ +import https from "https" +import url from "url" import AsyncLock from "async-lock" +import sizeOf from "image-size" import { AnalyzeDocumentChain, loadSummarizationChain } from "langchain/chains" import { OpenAI } from "langchain/llms/openai" import { PromptTemplate } from "langchain/prompts" @@ -7,7 +10,7 @@ import removeMarkdown from "remove-markdown" import { Metadata } from "@prisma/client" import { QueryClient } from "@tanstack/react-query" -import { toGateway } from "~/lib/ipfs-parser" +import { toCid, toGateway } from "~/lib/ipfs-parser" import prisma from "~/lib/prisma.server" import { cacheGet } from "~/lib/redis.server" import * as pageModel from "~/models/page.model" @@ -35,6 +38,145 @@ export const fetchGetPage = async ( }) } +export const getImageDimensionByUri = async (uri: string) => { + return new Promise<{ width: number; height: number; uri: string }>( + (resolve, reject) => { + const options = url.parse(uri) + + https.get(options, function (response) { + const chunks: Buffer[] = [] + let totalLength = 0 + const MAX_BUFFER_LENGTH = 10240 + + response + .on("data", function (chunk) { + chunks.push(chunk) + totalLength += chunk.length + + if (totalLength > MAX_BUFFER_LENGTH) { + response.destroy() + const buffer = Buffer.concat(chunks) + try { + const dimension = sizeOf(buffer) + if (dimension.width && dimension.height) { + resolve({ + width: dimension.width, + height: dimension.height, + uri, + }) + } + } catch (e) { + reject("Not enough data for image size detection.") + } + } + }) + .on("end", function () { + if (totalLength <= MAX_BUFFER_LENGTH) { + const buffer = Buffer.concat(chunks) + try { + const dimension = sizeOf(buffer) + if (dimension.width && dimension.height) { + resolve({ + width: dimension.width, + height: dimension.height, + uri, + }) + } + } catch (e) { + reject(e) + } + } + }) + }) + }, + ) +} + +export const cacheCoverDimensions = async ({ + cid, + uris, +}: { + cid: string + uris: string[] +}) => { + if (!uris.length) return + + const dimensions = (await cacheGet({ + key: ["coverDimensions", cid], + allowEmpty: true, + noUpdate: true, + noExpire: true, + getValueFun: async () => { + let result + + await lock.acquire(`coverDimensions_${cid}`, async () => { + const meta = await prisma.metadata.findFirst({ + where: { + uri: `ipfs://${cid}`, + }, + }) + + const key: keyof Metadata = "image_dimensions" + const dimensions = meta?.image_dimensions + + if (dimensions) { + result = dimensions + } else { + const newDimensions: Record< + string, + { width: number; height: number } + > = {} + + console.time(`get image dimensions ${cid}`) + for (const uri of uris) { + const dimension = await getImageDimensionByUri(toGateway(uri)) + if (dimension) { + newDimensions[uri] = dimension + } + } + console.timeEnd(`get image dimensions ${cid}`) + + if (meta) { + await prisma.metadata.update({ + where: { uri: `ipfs://${cid}` }, + data: { + [key]: newDimensions, + }, + }) + } else { + await prisma.metadata.create({ + data: { + uri: `ipfs://${cid}`, + [key]: newDimensions, + }, + }) + } + result = newDimensions + } + }) + + return result + }, + })) as Record | undefined + + return dimensions +} + +export const decoratePageForImageDimensions = async ( + page?: Awaited> | null, +) => { + if (!page) return + + const dimensions = await cacheCoverDimensions({ + cid: toCid(page?.metadata?.uri || ""), + uris: page?.metadata?.content?.images || [], + }) + + if (dimensions && Object.keys(dimensions).length && page?.metadata?.content) { + page.metadata.content["imageDimensions"] = dimensions + } +} + export const prefetchGetPagesBySite = async ( input: Parameters[0], queryClient: QueryClient,