Skip to content

Commit

Permalink
image optimization
Browse files Browse the repository at this point in the history
  • Loading branch information
malmz committed May 16, 2024
1 parent f5e3675 commit c685e81
Show file tree
Hide file tree
Showing 18 changed files with 319 additions and 99 deletions.
26 changes: 14 additions & 12 deletions app/components/album.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Link } from "@remix-run/react";
import { formatRelative } from "date-fns";
import { sv } from "date-fns/locale";
import { Link } from '@remix-run/react';
import { formatRelative } from 'date-fns';
import { sv } from 'date-fns/locale';

type Props = {
album: {
Expand All @@ -14,19 +14,21 @@ type Props = {

export function Album({ album }: Props) {
return (
<Link key={album.id} to={`/album/${album.id}`} className="space-y-2">
<div className="overflow-hidden rounded-lg">
<Link key={album.id} to={`/album/${album.id}`} className='space-y-2'>
<div className='overflow-hidden rounded-lg'>
<img
src={`/api/image/${album.thumbnail_id}`}
width="300"
height="200"
src={`/api/image/${album.thumbnail_id}?thumbnail`}
width='300'
height='200'
alt={album.name}
className="aspect-[3/2] h-[200px] w-[300px] object-cover transition-transform hover:scale-105"
decoding='async'
loading='lazy'
className='aspect-[3/2] h-[200px] w-[300px] object-cover transition-transform hover:scale-105'
></img>
</div>
<div className="flex flex-wrap justify-between px-2 text-sm">
<span className="font-medium leading-none">{album.name}</span>
<span className="text-xs text-muted-foreground">
<div className='flex flex-wrap justify-between px-2 text-sm'>
<span className='font-medium leading-none'>{album.name}</span>
<span className='text-xs text-muted-foreground'>
{formatRelative(album.created_at, new Date(), {
locale: sv,
weekStartsOn: 1,
Expand Down
7 changes: 4 additions & 3 deletions app/components/dynamic-breadcrum.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from './ui/breadcrumb';
import { Fragment } from 'react/jsx-runtime';

export type CrumbHandle<D = unknown> = {
breadcrumb: (
Expand Down Expand Up @@ -55,12 +56,12 @@ export function DynamicBreadcrum() {
match.handle.breadcrumb(match, match.pathname === pathname)
)
.map((match, i) => (
<>
<Fragment key={match.id}>
{i !== 0 && <BreadcrumbSeparator></BreadcrumbSeparator>}
<BreadcrumbItem key={match.id}>
<BreadcrumbItem>
<Crumb match={match}></Crumb>
</BreadcrumbItem>
</>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
Expand Down
2 changes: 1 addition & 1 deletion app/lib/session.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
createFileSessionStorage,
} from '@remix-run/node';
import { join } from 'path';
import { storagePath } from './storage.server';
import { storagePath } from './storage/paths';

// export the whole sessionStorage object
/*export const sessionStorage = createCookieSessionStorage({
Expand Down
31 changes: 0 additions & 31 deletions app/lib/storage.server.ts

This file was deleted.

49 changes: 49 additions & 0 deletions app/lib/storage/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
createReadableStreamFromReadable,
writeReadableStreamToWritable,
} from '@remix-run/node';
import { createReadStream, createWriteStream } from 'node:fs';
import { dirname } from 'node:path';
import { ImageError, type ImageRecord, type ImageStream } from './types';
import { safeStat } from './utils';
import { getLegacyImageStream } from './legacy';
import { stat, mkdir, rename } from 'node:fs/promises';
import { createOptimized } from './optimizer';
import { getImagePath, getPreviewPath, getThumbnailPath } from './paths';

export async function ensureImage(image: ImageRecord) {
const path = getImagePath(image);
let stats = await safeStat(path);
if (stats != null) return stats;
const imageStream = await getLegacyImageStream(image);
await mkdir(dirname(path), { recursive: true });
await writeReadableStreamToWritable(
imageStream.stream,
createWriteStream(path)
);
stats = await safeStat(path);
if (stats == null) throw new ImageError('image', image.id);
return stats;
}

export async function getImageStream(
image: ImageRecord,
ensure = false
): Promise<ImageStream> {
const path = getImagePath(image);
const stats = ensure ? await ensureImage(image) : await stat(path);
const stream = createReadableStreamFromReadable(createReadStream(path));
return {
id: image.id,
stream,
mimetype: image.mimetype ?? 'application/octet-stream',
size: stats.size,
};
}

export async function commitUpload(stagePath: string, image: ImageRecord) {
const path = getImagePath(image);
await mkdir(dirname(path), { recursive: true });
await rename(stagePath, path);
createOptimized(path, getThumbnailPath(image), getPreviewPath(image));
}
18 changes: 18 additions & 0 deletions app/lib/storage/legacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ImageError, type ImageRecord, type ImageStream } from './types';

export async function getLegacyImageStream(
image: ImageRecord
): Promise<ImageStream> {
const id = image.legacy_id;
if (!id) throw new ImageError('legacy', 'missing');
const res = await fetch(
`https://dfoto.se/v1/image/${image.legacy_id}/fullSize`
);
if (!res.ok || !res.body) throw new ImageError('legacy', id);
return {
id: image.id,
stream: res.body,
mimetype: image.mimetype ?? 'application/octet-stream',
size: null,
};
}
31 changes: 31 additions & 0 deletions app/lib/storage/optimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import sharp from 'sharp';

export async function createThumbnail(input: string, output: string) {
await sharp(input)
.resize(300, 200, { fit: 'cover', position: sharp.strategy.entropy })
.rotate()
.webp()
.toFile(output);
}

export async function createPreview(input: string, output: string) {
await sharp(input)
.resize(1200, 800, { fit: 'inside' })
.rotate()
.webp()
.toFile(output);
}

export async function createOptimized(
input: string,
thumbnailOutput: string,
previewOutput: string
) {
const pipeline = sharp(input).rotate().webp().clone();
await Promise.all([
pipeline
.resize(300, 200, { fit: 'cover', position: sharp.strategy.entropy })
.toFile(thumbnailOutput),
pipeline.resize(null, 800).toFile(previewOutput),
]);
}
31 changes: 31 additions & 0 deletions app/lib/storage/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { extension } from 'mime-types';
import { join } from 'node:path';

export const storagePath = process.env.STORAGE_PATH ?? './storage';
export const imagePath = join(storagePath, 'images');
export const thumbnailPath = join(storagePath, 'thumbnails');
export const previewPath = join(storagePath, 'previews');
export const uploadsPath = join(storagePath, 'uploads');

export function getImagePath(image: {
id: number;
album_id: number;
mimetype?: string | null;
}): string {
const ext = image.mimetype ? extension(image.mimetype) || '' : '';
return join(imagePath, image.album_id.toString(), `${image.id}.${ext}`);
}

export function getThumbnailPath(image: {
id: number;
album_id: number;
}): string {
return join(thumbnailPath, image.album_id.toString(), `${image.id}.webp`);
}

export function getPreviewPath(image: {
id: number;
album_id: number;
}): string {
return join(previewPath, image.album_id.toString(), `${image.id}.webp`);
}
37 changes: 37 additions & 0 deletions app/lib/storage/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createReadStream } from 'node:fs';
import { ImageError, type ImageRecord, type ImageStream } from './types';
import { safeStat } from './utils';
import { createReadableStreamFromReadable } from '@remix-run/node';
import { createPreview } from './optimizer';
import { stat, mkdir } from 'node:fs/promises';
import { getImagePath, getPreviewPath } from './paths';
import { ensureImage } from './image';
import { dirname } from 'node:path';

async function ensurePreview(image: ImageRecord) {
const path = getPreviewPath(image);
let stats = await safeStat(path);
if (stats != null) return stats;
await ensureImage(image);
const imagePath = getImagePath(image);
mkdir(dirname(path), { recursive: true });
await createPreview(imagePath, path);
stats = await safeStat(path);
if (stats == null) throw new ImageError('image', image.id);
return stats;
}

export async function getPreviewStream(
image: ImageRecord,
ensure = false
): Promise<ImageStream> {
const path = getPreviewPath(image);
const stats = ensure ? await ensurePreview(image) : await stat(path);
const stream = createReadableStreamFromReadable(createReadStream(path));
return {
id: image.id,
stream,
mimetype: 'image/webp',
size: stats.size,
};
}
37 changes: 37 additions & 0 deletions app/lib/storage/thumbnail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createReadStream } from 'node:fs';
import { ImageError, type ImageRecord, type ImageStream } from './types';
import { safeStat } from './utils';
import { createReadableStreamFromReadable } from '@remix-run/node';
import { ensureImage } from './image';
import { createThumbnail } from './optimizer';
import { stat, mkdir } from 'node:fs/promises';
import { getImagePath, getThumbnailPath } from './paths';
import { dirname } from 'node:path';

async function ensureThumbnail(image: ImageRecord) {
const path = getThumbnailPath(image);
let stats = await safeStat(path);
if (stats != null) return stats;
await ensureImage(image);
const imagePath = getImagePath(image);
mkdir(dirname(path), { recursive: true });
await createThumbnail(imagePath, path);
stats = await safeStat(path);
if (stats == null) throw new ImageError('image', image.id);
return stats;
}

export async function getThumbnailStream(
image: ImageRecord,
ensure = false
): Promise<ImageStream> {
const path = getThumbnailPath(image);
const stats = ensure ? await ensureThumbnail(image) : await stat(path);
const stream = createReadableStreamFromReadable(createReadStream(path));
return {
id: image.id,
stream,
mimetype: 'image/webp',
size: stats.size,
};
}
24 changes: 24 additions & 0 deletions app/lib/storage/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export class ImageError extends Error {
type: 'legacy' | 'image';
id: string | number;
constructor(type: 'legacy' | 'image', id: string | number) {
super(`Failed to fetch ${type} ${id}`);
this.name = 'ImageError';
this.id = id;
this.type = type;
}
}

export interface ImageStream {
id: number;
stream: ReadableStream;
mimetype: string | null;
size: number | null;
}

export interface ImageRecord {
id: number;
album_id: number;
mimetype?: string | null;
legacy_id?: string | null;
}
9 changes: 9 additions & 0 deletions app/lib/storage/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { stat } from 'node:fs/promises';

export async function safeStat(path: string) {
try {
return await stat(path);
} catch (e) {
return null;
}
}
19 changes: 11 additions & 8 deletions app/routes/_main._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,23 @@ import { Input } from '~/components/ui/input';
import { getAlbums, getPagesCount } from '~/lib/data.server';

export const loader = async ({ request }: LoaderFunctionArgs) => {
const queryParams = (new URL (request.url)).searchParams;
if (queryParams.get("page") === '1') throw redirect('/');
const page = Math.max(queryParams.get("page") ? parseInt(queryParams.get("page")!) : 1, 1);
const albums = getAlbums(page - 1, 28, queryParams.get("q")!);
const totalPages = await getPagesCount(28, queryParams.get("q")!);
const queryParams = new URL(request.url).searchParams;
if (queryParams.get('page') === '1') throw redirect('/');
const page = Math.max(
queryParams.get('page') ? parseInt(queryParams.get('page')!) : 1,
1
);
const albums = getAlbums(page - 1, 28, queryParams.get('q')!);
const totalPages = await getPagesCount(28, queryParams.get('q')!);
return {
page,
totalPages,
albums
albums,
};
};

export default function Page() {
const { totalPages, albums } = useLoaderData<typeof loader>();
const { totalPages, albums, page } = useLoaderData<typeof loader>();

return (
<>
Expand All @@ -41,7 +44,7 @@ export default function Page() {
</AutoGrid>
</Suspense>
</div>
<Paginator page={1} totalPages={totalPages} className='mt-3' />
<Paginator page={page} totalPages={totalPages} className='mt-3' />
</>
);
}
Loading

0 comments on commit c685e81

Please sign in to comment.