Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ images.small/
__pycache__/
.DS_Store
.vercel
dist/
package-lock.json
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This repo supports two complementary systems for image search and browsing:

- Local (MLX on Apple Silicon): run a lightweight Flask app that queries your Supabase Postgres with CLIP embeddings using MLX locally. Directory: `mlx-local/`.
- Cloud loader and embedding backfill (R2 + Replicate): upload images in `images/` to Cloudflare R2 and backfill missing embeddings via Replicate. Directory: `loader/`.
- Cloud browser: web interface for searching and browsing images. Directory: `browse/`.

The `loader` and `browse` applications share common code through the `shared/` library, which provides database operations and Replicate API interactions.

## Basic workflow

Expand All @@ -14,7 +17,7 @@ This repo supports two complementary systems for image search and browsing:
mise run sync
```

This scans `images/`, uploads new files to R2, and ensures a row exists in `image_embeddings`. A separate worker can backfill embeddings via Replicate.
This scans `images/`, uploads new files to R2, extracts image dimensions (width/height), and ensures a row exists in `image_embeddings`. A separate worker can backfill embeddings via Replicate.

## Local MLX browser (optional)

Expand All @@ -28,15 +31,19 @@ mise setup-supabase-env
mise run-mlx-local
```

## Architecture

The project uses a shared library (`shared/`) that contains common code for database operations and Replicate API interactions. Both `loader` and `browse` use this shared library to ensure consistency.

## Cloud loader details

See `loader/README.md` for full setup. In short, configure `loader/.env`, then:

```bash
# ensure DB schema exists
# ensure DB schema exists (includes width/height columns)
(cd loader && npm run ensure-schema)

# upload images and upsert DB rows (sync is a convenience wrapper)
# upload images, extract dimensions, and upsert DB rows (sync is a convenience wrapper)
mise sync

# backfill missing embeddings via Replicate
Expand Down
1 change: 1 addition & 0 deletions browse/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"image-browser-shared": "file:../shared",
"pg": "^8.12.0",
"replicate": "^0.31.1"
},
Expand Down
54 changes: 2 additions & 52 deletions browse/src/replicate.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,3 @@
import Replicate from "replicate";

type ReplicateModelId = `${string}/${string}` | `${string}/${string}:${string}`;

// Environment-driven configuration for Replicate text and image embeddings
const replicateToken = process.env.REPLICATE_API_TOKEN;
const DEFAULT_IMAGE_MODEL: ReplicateModelId =
"lucataco/clip-vit-base-patch32:056324d6fb78878c1016e432a3827fa76950022848c5378681dd99b7dc7dcc24";
// Default text model to krthr/clip-embeddings which returns { embedding: number[] }
const DEFAULT_TEXT_MODEL: ReplicateModelId =
"krthr/clip-embeddings:1c0371070cb827ec3c7f2f28adcdde54b50dcd239aa6faea0bc98b174ef03fb4";
const TEXT_MODEL: ReplicateModelId =
(process.env.REPLICATE_TEXT_MODEL as ReplicateModelId) || DEFAULT_TEXT_MODEL;
const TEXT_INPUT_KEY = process.env.REPLICATE_TEXT_INPUT_KEY || "text";
const IMAGE_MODEL: ReplicateModelId =
(process.env.REPLICATE_IMAGE_MODEL as ReplicateModelId) || DEFAULT_IMAGE_MODEL;
const IMAGE_INPUT_KEY = process.env.REPLICATE_IMAGE_INPUT_KEY || "image";

function getReplicateClient(): Replicate {
if (!replicateToken) {
throw new Error("REPLICATE_API_TOKEN is not set");
}
return new Replicate({ auth: replicateToken });
}

export async function getTextEmbedding(query: string): Promise<number[]> {
const client = getReplicateClient();
const input: Record<string, unknown> = {};
input[TEXT_INPUT_KEY] = query;
const result: unknown = await client.run(TEXT_MODEL, { input });
// Accept either raw number[] or { embedding: number[] }
if (Array.isArray(result)) {
return result.map((x) => Number(x));
}
if (result && typeof result === "object" && Array.isArray((result as any).embedding)) {
const { embedding } = result as { embedding: unknown[] };
return embedding.map((x) => Number(x));
}
throw new Error("Unexpected embedding result from Replicate (expected number[] or {embedding:number[]})");
}

export async function getImageEmbedding(imageUrl: string): Promise<number[]> {
const client = getReplicateClient();
const input: Record<string, unknown> = {};
input[IMAGE_INPUT_KEY] = imageUrl;
const result: unknown = await client.run(IMAGE_MODEL, { input });
if (Array.isArray(result)) {
return result.map((x) => Number(x));
}
throw new Error("Unexpected embedding result from Replicate (expected number[])");
}

// Re-export from shared library for backward compatibility
export { getTextEmbedding, getImageEmbedding } from "image-browser-shared";

69 changes: 44 additions & 25 deletions browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import express from "express";
import type { Request, Response } from "express";
import dotenv from "dotenv";
import { Pool, QueryResult } from "pg";
import { getTextEmbedding } from "./replicate.js";
import { getTextEmbedding, toVectorParam } from "image-browser-shared";

dotenv.config();

Expand Down Expand Up @@ -105,26 +105,32 @@ function listLocalImages(limit = 60): string[] {
}
}

async function listDbImages(limit = 60): Promise<string[]> {
interface ImageData {
file_name: string;
width?: number;
height?: number;
}

async function listDbImages(limit = 60): Promise<ImageData[]> {
if (!pool) return [];
const { rows }: QueryResult<{ file_name: string }> = await pool.query(
`SELECT file_name
const { rows }: QueryResult<ImageData> = await pool.query(
`SELECT file_name, width, height
FROM image_embeddings
WHERE embedding IS NOT NULL
ORDER BY created_at DESC
LIMIT $1;`,
[limit]
);
return rows.map((r) => r.file_name);
return rows;
}

// getTextEmbedding is imported from ./replicate

function toVectorParam(embedding: number[] | string): string {
if (typeof embedding === "string") return embedding; // expected like "[0.1,0.2,...]"
return `[${embedding.join(",")}]`;
function listLocalImagesAsData(limit = 60): ImageData[] {
const files = listLocalImages(limit);
return files.map(file_name => ({ file_name }));
}

// getTextEmbedding and toVectorParam are imported from shared library

// Load external HTML template and provide a renderer
const TEMPLATE_PATH = path.join(__dirname, "template.html");
let TEMPLATE_SOURCE = "";
Expand All @@ -150,13 +156,20 @@ function googleLensUrl(imageUrl: string): string {
return `${base}${encodeURIComponent(imageUrl)}`;
}

function renderTemplate(images: string[], query: string | null): string {
function isValidDimension(value: number | null | undefined): boolean {
return typeof value === 'number' && value > 0 && value < 100000 && Number.isInteger(value);
}

function renderTemplate(images: ImageData[], query: string | null): string {
const imagesHtml = images
.map((file) => {
const src = resolveImageUrl(file);
.map((img) => {
const src = resolveImageUrl(img.file_name);
const googleLink = USING_REMOTE_IMAGES ? `<a class=\"icon-btn\" href=\"${googleLensUrl(src)}\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Search on Google\" aria-label=\"Search on Google\">G</a>` : "";
const downloadLink = `<a class=\"icon-btn\" href=\"${src}\" download title=\"Download image\" aria-label=\"Download image\">↓</a>`;
return `\n <div class=\"masonry-item\">\n <div class=\"image-wrap\">\n <a class=\"image-link\" href=\"/neighbors/${encodeURIComponent(file)}\">\n <img src=\"${src}\" alt=\"${file}\" />\n </a>\n <div class=\"overlay-actions\">\n ${googleLink}${googleLink ? "\n " : ""}${downloadLink}\n </div>\n </div>\n </div>`;
// Add width and height attributes if available and valid for better page loading
const hasValidDimensions = isValidDimension(img.width) && isValidDimension(img.height);
const dimensionAttrs = hasValidDimensions ? ` width="${img.width}" height="${img.height}"` : "";
return `\n <div class=\"masonry-item\">\n <div class=\"image-wrap\">\n <a class=\"image-link\" href=\"/neighbors/${encodeURIComponent(img.file_name)}\">\n <img src=\"${src}\" alt=\"${img.file_name}\"${dimensionAttrs} />\n </a>\n <div class=\"overlay-actions\">\n ${googleLink}${googleLink ? "\n " : ""}${downloadLink}\n </div>\n </div>\n </div>`;
})
.join("");

Expand Down Expand Up @@ -204,24 +217,24 @@ app.get("/", async (req: Request, res: Response) => {
}
const vec = toVectorParam(embedding);
if (!pool) throw new Error("Database not configured");
const { rows }: QueryResult<{ file_name: string; distance: number }> = await pool.query(
`SELECT file_name, embedding <#> $1::vector AS distance
const { rows }: QueryResult<ImageData & { distance: number }> = await pool.query(
`SELECT file_name, width, height, embedding <#> $1::vector AS distance
FROM image_embeddings
WHERE embedding IS NOT NULL
ORDER BY distance ASC
LIMIT 30;`,
[vec]
);
const images = rows.map((r) => r.file_name);
const images: ImageData[] = rows.map((r) => ({ file_name: r.file_name, width: r.width, height: r.height }));
res.type("html").send(renderTemplate(images, q));
return;
}
// No query: list from DB if available, else fallback to local dir
let images: string[] = [];
let images: ImageData[] = [];
if (pool) {
images = await listDbImages(60);
} else {
images = listLocalImages(60);
images = listLocalImagesAsData(60);
}
res.type("html").send(renderTemplate(images, null));
} catch (err) {
Expand All @@ -235,9 +248,9 @@ app.get("/neighbors/:file_name", async (req: Request, res: Response) => {
const fileName = req.params.file_name;
try {
if (!pool) throw new Error("Database not configured");
// Fetch embedding for the selected image as text for reuse
const { rows: embRows }: QueryResult<{ embedding_text: string }> = await pool.query(
`SELECT embedding::text AS embedding_text
// Fetch embedding and dimensions for the selected image
const { rows: embRows }: QueryResult<{ embedding_text: string; width?: number; height?: number }> = await pool.query(
`SELECT embedding::text AS embedding_text, width, height
FROM image_embeddings
WHERE file_name = $1 AND embedding IS NOT NULL
LIMIT 1;`,
Expand All @@ -248,15 +261,21 @@ app.get("/neighbors/:file_name", async (req: Request, res: Response) => {
return;
}
const embeddingText = embRows[0].embedding_text;
const { rows }: QueryResult<{ file_name: string; distance: number }> = await pool.query(
`SELECT file_name, embedding <#> $1::vector AS distance
const selectedImage: ImageData = {
file_name: fileName,
width: embRows[0].width,
height: embRows[0].height
};
const { rows }: QueryResult<ImageData & { distance: number }> = await pool.query(
`SELECT file_name, width, height, embedding <#> $1::vector AS distance
FROM image_embeddings
WHERE file_name != $2 AND embedding IS NOT NULL
ORDER BY distance ASC
LIMIT 30;`,
[embeddingText, fileName]
);
const images = [fileName, ...rows.map((r) => r.file_name)];
const similarImages: ImageData[] = rows.map((r) => ({ file_name: r.file_name, width: r.width, height: r.height }));
const images = [selectedImage, ...similarImages];
res.type("html").send(renderTemplate(images, null));
} catch (err) {
// eslint-disable-next-line no-console
Expand Down
8 changes: 5 additions & 3 deletions loader/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Loader (Cloudflare R2 + Embedding Backfill)

This project uploads local images to Cloudflare R2 and ensures there is a row in Postgres (`image_embeddings`). A second worker backfills missing image embeddings using Replicate.
This project uploads local images to Cloudflare R2, extracts image dimensions (width/height), and ensures there is a row in Postgres (`image_embeddings`). A second worker backfills missing image embeddings using Replicate.

The loader uses shared library code from `../shared/` for database operations and Replicate API interactions, ensuring consistency with the browse application.

## Setup

Expand Down Expand Up @@ -46,13 +48,13 @@ npm install

## Commands

- Ensure schema (creates `image_embeddings` if missing):
- Ensure schema (creates `image_embeddings` with width/height columns if missing):

```bash
npm run ensure-schema
```

- Upload images (skips ones already in R2) and upsert DB rows:
- Upload images (skips ones already in R2), extract dimensions, and upsert DB rows:

```bash
npm run upload
Expand Down
6 changes: 4 additions & 2 deletions loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@
"scripts": {
"upload": "tsx src/upload_r2.ts",
"embed": "tsx src/embed_missing.ts",
"ensure-schema": "tsx src/db.ts ensure-schema",
"ensure-schema": "tsx ../shared/src/db.ts ensure-schema",
"sync": "tsx src/sync.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.614.0",
"dotenv": "^16.4.5",
"image-browser-shared": "file:../shared",
"mime": "^3.0.0",
"p-limit": "^5.0.0",
"pg": "^8.12.0",
"replicate": "^0.31.1"
"replicate": "^0.31.1",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/node": "^22.8.4",
Expand Down
Loading