Skip to content
File

Blob: src/worker/queues/site-cover.ts

typescript270 lines
1import { eq } from "drizzle-orm";
2 
3import { isGradientPreset, parseUploadCoverUrl } from "@/shared/page-cover";
4import { createSessionDb, type Db } from "@/worker/db/d1/client";
5import { pages } from "@/worker/db/d1/schema";
6import { createLogger } from "@/worker/lib/logger";
7import {
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";
23import type { TasksQueueResult } from "./messages";
24 
25const log = createLogger("site-cover");
26const COLOR_TABLE_SIZE = 1001;
27const UPLOAD_RETRY_DELAY_SECONDS = 30;
28 
29interface SiteCoverPage {
30 id: string;
31 workspace_id: string;
32 kind: "doc" | "canvas";
33 cover_url: string | null;
34 archived_at: string | null;
35}
36 
37export 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 
106async 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 
164async 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 
182async 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 
196async 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 
207function 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 
237function 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 
261function interpolateByte(from: number, to: number, t: number): number {
262 return Math.round(from + (to - from) * Math.min(1, Math.max(0, t)));
263}
264 
265function 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}