File
Blob: src/worker/queues/site-cover.ts
| 1 | import { eq } from "drizzle-orm"; |
| 2 | |
| 3 | import { isGradientPreset, parseUploadCoverUrl } from "@/shared/page-cover"; |
| 4 | import { createSessionDb, type Db } from "@/worker/db/d1/client"; |
| 5 | import { pages } from "@/worker/db/d1/schema"; |
| 6 | import { createLogger } from "@/worker/lib/logger"; |
| 7 | import { |
| 8 | coverMatches, |
| 9 | coverMetadata, |
| 10 | createSiteCoverHash, |
| 11 | parseGradient, |
| 12 | resolveUploadCover, |
| 13 | SITE_COVER_ANIMATED, |
| 14 | SITE_COVER_FIT, |
| 15 | SITE_COVER_FORMAT, |
| 16 | SITE_COVER_GRAVITY, |
| 17 | SITE_COVER_HEIGHT, |
| 18 | SITE_COVER_WIDTH, |
| 19 | siteCoverKey, |
| 20 | type SiteCoverSource, |
| 21 | type SupportedLinearGradient, |
| 22 | } from "@/worker/sites/cover"; |
| 23 | import type { TasksQueueResult } from "./messages"; |
| 24 | |
| 25 | const log = createLogger("site-cover"); |
| 26 | const COLOR_TABLE_SIZE = 1001; |
| 27 | const UPLOAD_RETRY_DELAY_SECONDS = 30; |
| 28 | |
| 29 | interface SiteCoverPage { |
| 30 | id: string; |
| 31 | workspace_id: string; |
| 32 | kind: "doc" | "canvas"; |
| 33 | cover_url: string | null; |
| 34 | archived_at: string | null; |
| 35 | } |
| 36 | |
| 37 | export async function handleSiteCover(pageId: string, env: Env): Promise<TasksQueueResult> { |
| 38 | const { db } = createSessionDb(env.DB, "first-primary"); |
| 39 | const page = await db |
| 40 | .select({ |
| 41 | id: pages.id, |
| 42 | workspace_id: pages.workspace_id, |
| 43 | kind: pages.kind, |
| 44 | cover_url: pages.cover_url, |
| 45 | archived_at: pages.archived_at, |
| 46 | }) |
| 47 | .from(pages) |
| 48 | .where(eq(pages.id, pageId)) |
| 49 | .get(); |
| 50 | |
| 51 | if (!page) { |
| 52 | log.info("site_cover_retry", { pageId, reason: "page_not_yet_visible" }); |
| 53 | return { kind: "retry", delaySeconds: 2 }; |
| 54 | } |
| 55 | if (page.archived_at || page.kind !== "doc") { |
| 56 | log.info("site_cover_skipped", { |
| 57 | pageId, |
| 58 | workspaceId: page.workspace_id, |
| 59 | kind: page.kind, |
| 60 | archived: !!page.archived_at, |
| 61 | }); |
| 62 | return { kind: "ok" }; |
| 63 | } |
| 64 | |
| 65 | const coverUrl = page.cover_url; |
| 66 | if (!coverUrl) { |
| 67 | log.info("site_cover_skipped", { pageId, workspaceId: page.workspace_id, reason: "empty" }); |
| 68 | return { kind: "ok" }; |
| 69 | } |
| 70 | |
| 71 | const uploadId = parseUploadCoverUrl(coverUrl); |
| 72 | if (uploadId) return handleUpload(db, page, uploadId, env); |
| 73 | |
| 74 | if (!isGradientPreset(coverUrl)) { |
| 75 | log.info("site_cover_skipped", { pageId, workspaceId: page.workspace_id, reason: "unsupported_cover" }); |
| 76 | return { kind: "ok" }; |
| 77 | } |
| 78 | |
| 79 | const gradient = parseGradient(coverUrl); |
| 80 | if (!gradient) { |
| 81 | log.info("site_cover_skipped", { pageId, workspaceId: page.workspace_id, reason: "unsupported_gradient" }); |
| 82 | return { kind: "ok" }; |
| 83 | } |
| 84 | |
| 85 | const coverHash = await createSiteCoverHash(coverUrl); |
| 86 | const key = siteCoverKey(page.workspace_id, page.id); |
| 87 | const existing = await env.SITES.get(key); |
| 88 | if (existing && coverMatches(existing, coverUrl, coverHash)) { |
| 89 | log.info("site_cover_skipped", { pageId, workspaceId: page.workspace_id, reason: "fresh" }); |
| 90 | return { kind: "ok" }; |
| 91 | } |
| 92 | |
| 93 | const png = await renderGradientPng(gradient); |
| 94 | await putCover(env, page, coverUrl, coverHash, "gradient", png); |
| 95 | |
| 96 | log.info("site_cover_written", { |
| 97 | pageId, |
| 98 | workspaceId: page.workspace_id, |
| 99 | bytes: png.byteLength, |
| 100 | width: SITE_COVER_WIDTH, |
| 101 | height: SITE_COVER_HEIGHT, |
| 102 | }); |
| 103 | return { kind: "ok" }; |
| 104 | } |
| 105 | |
| 106 | async function handleUpload( |
| 107 | db: Db, |
| 108 | page: Pick<SiteCoverPage, "id" | "workspace_id" | "cover_url">, |
| 109 | uploadId: string, |
| 110 | env: Env, |
| 111 | ): Promise<TasksQueueResult> { |
| 112 | const coverUrl = page.cover_url; |
| 113 | if (!coverUrl) return { kind: "ok" }; |
| 114 | |
| 115 | const upload = await resolveUploadCover(db, page, uploadId); |
| 116 | if (!upload) { |
| 117 | log.info("site_cover_skipped", { pageId: page.id, workspaceId: page.workspace_id, reason: "unsupported_upload" }); |
| 118 | return { kind: "ok" }; |
| 119 | } |
| 120 | |
| 121 | const coverHash = await createSiteCoverHash(coverUrl); |
| 122 | const key = siteCoverKey(page.workspace_id, page.id); |
| 123 | const existing = await env.SITES.get(key); |
| 124 | if (existing && coverMatches(existing, coverUrl, coverHash)) { |
| 125 | log.info("site_cover_skipped", { pageId: page.id, workspaceId: page.workspace_id, reason: "fresh" }); |
| 126 | return { kind: "ok" }; |
| 127 | } |
| 128 | |
| 129 | const object = await env.R2.get(upload.r2Key); |
| 130 | if (!object) { |
| 131 | log.info("site_cover_skipped", { |
| 132 | pageId: page.id, |
| 133 | workspaceId: page.workspace_id, |
| 134 | reason: "upload_object_missing", |
| 135 | }); |
| 136 | return { kind: "ok" }; |
| 137 | } |
| 138 | |
| 139 | let transformed: ArrayBuffer; |
| 140 | try { |
| 141 | transformed = await renderUpload(env.IMAGES, object.body); |
| 142 | } catch (error) { |
| 143 | log.warn("site_cover_transform_retry", { |
| 144 | pageId: page.id, |
| 145 | workspaceId: page.workspace_id, |
| 146 | uploadId: upload.id, |
| 147 | error: error instanceof Error ? error.message : String(error), |
| 148 | }); |
| 149 | return { kind: "retry", delaySeconds: UPLOAD_RETRY_DELAY_SECONDS }; |
| 150 | } |
| 151 | |
| 152 | await putCover(env, page, coverUrl, coverHash, "upload", transformed); |
| 153 | log.info("site_cover_written", { |
| 154 | pageId: page.id, |
| 155 | workspaceId: page.workspace_id, |
| 156 | uploadId: upload.id, |
| 157 | width: SITE_COVER_WIDTH, |
| 158 | height: SITE_COVER_HEIGHT, |
| 159 | source: "upload", |
| 160 | }); |
| 161 | return { kind: "ok" }; |
| 162 | } |
| 163 | |
| 164 | async function renderUpload(images: ImagesBinding, input: ReadableStream<Uint8Array>): Promise<ArrayBuffer> { |
| 165 | const result = await images |
| 166 | .input(input) |
| 167 | .transform({ |
| 168 | width: SITE_COVER_WIDTH, |
| 169 | height: SITE_COVER_HEIGHT, |
| 170 | fit: SITE_COVER_FIT, |
| 171 | gravity: SITE_COVER_GRAVITY, |
| 172 | }) |
| 173 | .output({ |
| 174 | format: SITE_COVER_FORMAT, |
| 175 | anim: SITE_COVER_ANIMATED, |
| 176 | }); |
| 177 | const response = result.response(); |
| 178 | if (!response.body) throw new Error("Images produced no cover body"); |
| 179 | return response.arrayBuffer(); |
| 180 | } |
| 181 | |
| 182 | async function putCover( |
| 183 | env: Pick<Env, "SITES">, |
| 184 | page: Pick<SiteCoverPage, "id" | "workspace_id">, |
| 185 | coverUrl: string, |
| 186 | coverHash: string, |
| 187 | source: SiteCoverSource, |
| 188 | body: ArrayBuffer | ArrayBufferView, |
| 189 | ): Promise<void> { |
| 190 | await env.SITES.put(siteCoverKey(page.workspace_id, page.id), body, { |
| 191 | httpMetadata: { contentType: SITE_COVER_FORMAT }, |
| 192 | customMetadata: coverMetadata(coverUrl, coverHash, source), |
| 193 | }); |
| 194 | } |
| 195 | |
| 196 | async function renderGradientPng(gradient: SupportedLinearGradient): Promise<Uint8Array> { |
| 197 | const rgba = rasterizeGradient(gradient); |
| 198 | const { BitDepth, ColorType, Compression, FilterType, encode } = await import("@cf-wasm/png/workerd"); |
| 199 | return encode(rgba, SITE_COVER_WIDTH, SITE_COVER_HEIGHT, { |
| 200 | color: ColorType.RGBA, |
| 201 | depth: BitDepth.Eight, |
| 202 | compression: Compression.Fast, |
| 203 | filter: FilterType.NoFilter, |
| 204 | }); |
| 205 | } |
| 206 | |
| 207 | function rasterizeGradient(gradient: SupportedLinearGradient): Uint8Array { |
| 208 | const pixels = new Uint8Array(SITE_COVER_WIDTH * SITE_COVER_HEIGHT * 4); |
| 209 | const colors = buildColorTable(gradient); |
| 210 | const radians = (gradient.angleDeg * Math.PI) / 180; |
| 211 | const dx = Math.sin(radians); |
| 212 | const dy = -Math.cos(radians); |
| 213 | const corners = [0, SITE_COVER_WIDTH * dx, SITE_COVER_HEIGHT * dy, SITE_COVER_WIDTH * dx + SITE_COVER_HEIGHT * dy]; |
| 214 | const minProjection = Math.min(...corners); |
| 215 | const maxProjection = Math.max(...corners); |
| 216 | const scale = (COLOR_TABLE_SIZE - 1) / Math.max(maxProjection - minProjection, 1); |
| 217 | const xStep = dx * scale; |
| 218 | |
| 219 | let offset = 0; |
| 220 | for (let y = 0; y < SITE_COVER_HEIGHT; y += 1) { |
| 221 | let colorIndex = ((y + 0.5) * dy + 0.5 * dx - minProjection) * scale; |
| 222 | for (let x = 0; x < SITE_COVER_WIDTH; x += 1) { |
| 223 | const tableIndex = clampColorIndex(colorIndex); |
| 224 | const tableOffset = tableIndex * 4; |
| 225 | pixels[offset] = colors[tableOffset]; |
| 226 | pixels[offset + 1] = colors[tableOffset + 1]; |
| 227 | pixels[offset + 2] = colors[tableOffset + 2]; |
| 228 | pixels[offset + 3] = 255; |
| 229 | offset += 4; |
| 230 | colorIndex += xStep; |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | return pixels; |
| 235 | } |
| 236 | |
| 237 | function buildColorTable(gradient: SupportedLinearGradient): Uint8Array { |
| 238 | const colors = new Uint8Array(COLOR_TABLE_SIZE * 4); |
| 239 | let segment = 0; |
| 240 | |
| 241 | for (let i = 0; i < COLOR_TABLE_SIZE; i += 1) { |
| 242 | const position = (i / (COLOR_TABLE_SIZE - 1)) * 100; |
| 243 | while (segment < gradient.stops.length - 2 && position > gradient.stops[segment + 1].position) { |
| 244 | segment += 1; |
| 245 | } |
| 246 | |
| 247 | const from = gradient.stops[segment]; |
| 248 | const to = gradient.stops[Math.min(segment + 1, gradient.stops.length - 1)]; |
| 249 | const span = to.position - from.position; |
| 250 | const t = span <= 0 ? 1 : (position - from.position) / span; |
| 251 | const offset = i * 4; |
| 252 | colors[offset] = interpolateByte(from.r, to.r, t); |
| 253 | colors[offset + 1] = interpolateByte(from.g, to.g, t); |
| 254 | colors[offset + 2] = interpolateByte(from.b, to.b, t); |
| 255 | colors[offset + 3] = 255; |
| 256 | } |
| 257 | |
| 258 | return colors; |
| 259 | } |
| 260 | |
| 261 | function interpolateByte(from: number, to: number, t: number): number { |
| 262 | return Math.round(from + (to - from) * Math.min(1, Math.max(0, t))); |
| 263 | } |
| 264 | |
| 265 | function clampColorIndex(value: number): number { |
| 266 | if (value <= 0) return 0; |
| 267 | if (value >= COLOR_TABLE_SIZE - 1) return COLOR_TABLE_SIZE - 1; |
| 268 | return Math.round(value); |
| 269 | } |